├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── cypress.yml │ ├── deploy-dev.yml │ ├── deploy-main.yml │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── LICENSE ├── README.md ├── cypress.json ├── cypress ├── fixtures │ └── example.json ├── integration │ └── charts │ │ ├── BarChart.js │ │ ├── LineChart.js │ │ └── PieChart.js ├── plugins │ └── index.js └── support │ ├── commands.js │ └── index.js ├── data ├── EceColorScheme.json ├── aapl.json ├── coffee_shop.json ├── countries.json ├── fruit.json ├── historical_gdp.json ├── life_expectancy.json ├── ny_unemployment.json ├── penguins.json ├── police-data.json ├── portfolio.json ├── sales.json ├── skinny_fruit.json ├── skinny_fruit_numbers.json ├── unemployment.json └── unemployment_discreet.json ├── global-setup.js ├── jest.config.ts ├── package-lock.json ├── package.json ├── public └── index.html ├── src ├── App.tsx ├── charts │ ├── AreaChart │ │ └── AreaChart.tsx │ ├── BarChart │ │ └── BarChart.tsx │ ├── LineChart │ │ └── LineChart.tsx │ ├── PieChart │ │ └── PieChart.tsx │ └── ScatterPlot │ │ └── ScatterPlot.tsx ├── components │ ├── Arc.tsx │ ├── Circle.tsx │ ├── ColorLegend.tsx │ ├── ContinuousAxis.tsx │ ├── DiscreteAxis.tsx │ ├── Label.tsx │ ├── Line.tsx │ ├── ListeningRect.tsx │ ├── Rectangle.tsx │ ├── Tooltip.tsx │ ├── TooltipContent.tsx │ ├── VoronoiCell.tsx │ └── VoronoiWrapper.tsx ├── functionality │ ├── grid.tsx │ ├── voronoi.tsx │ ├── xScale.tsx │ └── yScale.tsx ├── hooks │ ├── useD3.tsx │ ├── useEnvEffect.tsx │ ├── useMousePosition.tsx │ ├── useResponsive.tsx │ └── useWindowDimensions.tsx ├── index.dev.tsx ├── index.tsx ├── styles │ ├── componentStyles.ts │ └── globals.ts └── utils.ts ├── tests ├── Test.tsx ├── components │ ├── Circle.test.tsx │ ├── ContinuousAxis.test.tsx │ ├── DiscreteAxis.test.tsx │ └── Line.test.tsx ├── global-setup.js ├── global-setup.test.ts └── index.cypress.tsx ├── tsconfig.json ├── tsconfig.webpack.json ├── types.ts ├── webpack.common.ts ├── webpack.config.ts ├── webpack.cypress.ts ├── webpack.dev.ts └── webpack.prod.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:import/errors", 5 | "plugin:react/recommended", 6 | "plugin:react-hooks/recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 9 | "prettier" 10 | ], 11 | "rules": { 12 | "react/prop-types": 0, 13 | "react/react-in-jsx-scope": 0, 14 | "@typescript-eslint/no-empty-function": 0, 15 | "@typescript-eslint/no-explicit-any": 0, 16 | "@typescript-eslint/no-unsafe-assignment": 0, 17 | "@typescript-eslint/no-unsafe-argument": 0, 18 | "@typescript-eslint/restrict-template-expression": 0, 19 | "@typescript-eslint/no-unsafe-member-access": 0, 20 | "@typescript-eslint/no-unsafe-return": 0, 21 | "@typescript-eslint/no-unsafe-call": 0, 22 | "react/display-name": 0, 23 | "react-hooks/exhaustive-deps": "off" 24 | }, 25 | "plugins": ["react", "import", "@typescript-eslint"], 26 | "parser": "@typescript-eslint/parser", 27 | "parserOptions": { 28 | "project": "./tsconfig.json", 29 | "ecmaVersion": 2020, 30 | "sourceType": "module", 31 | "ecmaFeatures": { 32 | "jsx": true 33 | } 34 | }, 35 | "env": { 36 | "es6": true, 37 | "browser": true, 38 | "node": true 39 | }, 40 | "settings": { 41 | "react": { 42 | "version": "detect" 43 | }, 44 | "import/parsers": { 45 | "@typescript-eslint/parser": [".ts", ".tsx"] 46 | }, 47 | "import/resolver": { 48 | "typescript": { 49 | "alwaysTryTypes": true 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.github/workflows/cypress.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: DEV CI 4 | 5 | # Controls when the workflow will run 6 | on: 7 | pull_request: 8 | branches: [dev] 9 | 10 | # Allows you to run this workflow manually from the Actions tab 11 | workflow_dispatch: 12 | 13 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 14 | jobs: 15 | cypress-run: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: cypress-io/github-action@v2 20 | with: 21 | start: npm run test-cypress 22 | wait-on: "http://localhost:8080" 23 | browser: chrome 24 | headless: true 25 | 26 | -------------------------------------------------------------------------------- /.github/workflows/deploy-dev.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: DEV CI 4 | 5 | # Controls when the workflow will run 6 | on: 7 | push: 8 | branches: [ dev ] 9 | 10 | # Allows you to run this workflow manually from the Actions tab 11 | workflow_dispatch: 12 | 13 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 14 | jobs: 15 | deploy: 16 | runs-on: ubuntu-latest 17 | # Steps represent a sequence of tasks that will be executed as part of the job 18 | steps: 19 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 20 | - uses: actions/checkout@v2 21 | 22 | # Install dependencies 23 | - name: Install 24 | run: npm install 25 | 26 | # Build package 27 | - name: Build 28 | run: npm run build 29 | 30 | - name: NPM Publish to github package repo 31 | uses: JS-DevTools/npm-publish@v1 32 | with: 33 | token: ${{ secrets.GITHUB_TOKEN }} 34 | registry: https://npm.pkg.github.com/ 35 | -------------------------------------------------------------------------------- /.github/workflows/deploy-main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: PROD CI 4 | 5 | # Controls when the workflow will run 6 | on: 7 | push: 8 | tags: 9 | - '*' 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 15 | jobs: 16 | deploy: 17 | runs-on: ubuntu-latest 18 | # Steps represent a sequence of tasks that will be executed as part of the job 19 | steps: 20 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 21 | - uses: actions/checkout@v2 22 | 23 | # Install dependencies 24 | - name: Install 25 | run: npm install 26 | 27 | # Strip scope from package.json name 28 | - name: Unscope 29 | run: sed -i"" -e "s/@oslabs-beta\\/d3reactor/d3reactor/" package.json 30 | 31 | # Build package 32 | - name: Build 33 | run: npm run build 34 | 35 | - name: NPM Publish to github package repo 36 | uses: JS-DevTools/npm-publish@v1 37 | with: 38 | token: ${{ secrets.NPM_TOKEN }} 39 | registry: https://registry.npmjs.org/ 40 | # only run if published version number differs from the one in package.json 41 | check-version: true -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | name: DEV CI 3 | 4 | # Controls when the workflow will run 5 | on: 6 | pull_request: 7 | branches: [ dev ] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 13 | jobs: 14 | # This workflow contains a single job called "format" 15 | format-lint: 16 | # The type of runner that the job will run on 17 | runs-on: ubuntu-latest 18 | 19 | # Steps represent a sequence of tasks that will be executed as part of the job 20 | steps: 21 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 22 | - uses: actions/checkout@v2 23 | 24 | # Install dependencies 25 | - name: Install 26 | run: npm install 27 | 28 | # Format project 29 | - name: Format project 30 | run: npm run format 31 | 32 | # Lint project 33 | - name: Lint project 34 | run: npm run lint -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: DEV CI 4 | 5 | # Controls when the workflow will run 6 | on: 7 | pull_request: 8 | branches: [dev] 9 | 10 | # Allows you to run this workflow manually from the Actions tab 11 | workflow_dispatch: 12 | 13 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | # Steps represent a sequence of tasks that will be executed as part of the job 18 | steps: 19 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 20 | - uses: actions/checkout@v2 21 | 22 | # Install dependencies 23 | - name: Install 24 | run: npm install 25 | 26 | - name: Test 27 | run: npm test 28 | 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | .DS_Store 107 | 108 | # Cypress 109 | cypress/screenshots 110 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | 2 | <<<<<<< HEAD 3 | @oslabs-beta:registry=https://npm.pkg.github.com 4 | ======= 5 | @oslabs-beta:registry=https://npm.pkg.github.com 6 | >>>>>>> main 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 OSLabs Beta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # d3reactor 2 | 3 | An open-source library of charts for creating performant, responsive data visualizations built with React and D3. 4 | 5 | The main goal of this library to help you create customizable charts easily. 6 | 7 | # Installation 8 | Let's get your first d3reactor chart setup in less < 5 minutes. 9 | 10 | ## Install the d3reactor package 11 | ``` 12 | npm install d3reactor 13 | ``` 14 | OR 15 | ``` 16 | yarn add d3reactor 17 | ``` 18 | 19 | ## Import d3reactor into your React project 20 | 21 | ``` 22 | import * as d3reactor from "d3reactor" 23 | ``` 24 | 25 | OR you can import each chart separately 26 | 27 | ``` 28 | import {AreaChart, BarChart, PieChart, ScatterPlot, LineChart} from "d3reactor" 29 | ``` 30 | 31 | # Examples 32 | 33 | ``` 34 | 40 | 41 | ``` 42 | 43 | And you're good to go! 44 | 45 | Stacked Bar Chart 46 | 47 | 48 | # Documentation 49 | 50 | For detailed information, please follow the links below: 51 | 52 | * [d3reactor](https://www.d3reactor.com/) 53 | * [Area Chart](https://www.docs.d3reactor.com/docs/Charts/area-chart) 54 | * [Bar Chart](https://www.docs.d3reactor.com/docs/Charts/bar-chart) 55 | * [Line Chart](https://www.docs.d3reactor.com/docs/Charts/line-chart) 56 | * [Pie Chart](https://www.docs.d3reactor.com/docs/Charts/pie-chart) 57 | * [Scatter Plot](https://www.docs.d3reactor.com/docs/Charts/scatter-plot) 58 | 59 | 60 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "video":false 3 | } 4 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/integration/charts/BarChart.js: -------------------------------------------------------------------------------- 1 | describe('Bar Chart', () => { 2 | it('should display bar chart', () => { 3 | const width = 1000; 4 | const height = 900; 5 | cy.visit('localhost:8080'); 6 | cy.viewport(width, height); 7 | cy.get('[data-testid = bar-chart]') 8 | .should('be.visible') 9 | .and((chart) => { 10 | expect(chart.width()).to.eql(width); 11 | expect(chart.height()).to.eql(height); 12 | }); 13 | cy.get('[data-testid = bar-chart-legend]').should('be.visible'); 14 | cy.get('[data-testid = rectangle-1]').trigger('mouseover'); 15 | cy.get('[data-testid = tooltip-bar-chart]') 16 | .should('be.visible') 17 | .should('contain', 'date 2019-07-23'); 18 | cy.get('[data-testid = bar-chart-x-axis-label]') 19 | .should('be.visible') 20 | .should('contain', 'Date'); 21 | cy.get('[data-testid = bar-chart-x-axis]').should('be.visible'); 22 | cy.get('[data-testid = bar-chart-y-axis-label]') 23 | .should('be.visible') 24 | .should('contain', 'Value'); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /cypress/integration/charts/LineChart.js: -------------------------------------------------------------------------------- 1 | describe('Line Chart', () => { 2 | it('should display line chart', () => { 3 | const width = 600; 4 | const height = 500; 5 | cy.visit('localhost:8080'); 6 | cy.viewport(width, height); 7 | cy.get('[data-testid = line-chart]') 8 | .should('be.visible') 9 | .and((chart) => { 10 | expect(chart.height()).to.eql(500); 11 | expect(chart.width()).to.eql(600); 12 | }); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /cypress/integration/charts/PieChart.js: -------------------------------------------------------------------------------- 1 | describe('Pie Chart', () => { 2 | it('should display pie chart', () => { 3 | cy.visit('localhost:8080'); 4 | cy.get('[data-testid = pie-chart]').should('be.visible'); 5 | cy.get('[data-testid = pie-chart-legend]') 6 | .should('be.visible') 7 | .should('contain', 'fruit'); 8 | cy.get('[data-testid = pie-chart-arc-text-2]') 9 | .should('be.visible') 10 | .should('contain', '30'); 11 | // cy.get('[data-testid = pie-chart-arc-2]').trigger('mouseover'); 12 | //TODO: investigate why the tooltip is not visible 13 | // cy.get('[data-testid = tooltip-pie-chart]').should('be.visible'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | // eslint-disable-next-line no-unused-vars 19 | module.exports = (on, config) => { 20 | // `on` is used to hook into various events Cypress emits 21 | // `config` is the resolved Cypress config 22 | } 23 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /data/EceColorScheme.json: -------------------------------------------------------------------------------- 1 | [ 2 | "#f7fcfd", 3 | "#f6fbfd", 4 | "#f6fbfc", 5 | "#f5fafc", 6 | "#f4fafc", 7 | "#f3f9fc", 8 | "#f3f9fb", 9 | "#f2f8fb", 10 | "#f1f8fb", 11 | "#f0f7fa", 12 | "#f0f7fa", 13 | "#eff6fa", 14 | "#eef6fa", 15 | "#eef5f9", 16 | "#edf5f9", 17 | "#ecf4f9", 18 | "#ebf4f8", 19 | "#eaf3f8", 20 | "#eaf3f8", 21 | "#e9f2f7", 22 | "#e8f2f7", 23 | "#e7f1f7", 24 | "#e7f0f7", 25 | "#e6f0f6", 26 | "#e5eff6", 27 | "#e4eff6", 28 | "#e3eef5", 29 | "#e3eef5", 30 | "#e2edf5", 31 | "#e1ecf4", 32 | "#e0ecf4", 33 | "#dfebf3", 34 | "#deeaf3", 35 | "#ddeaf3", 36 | "#dce9f2", 37 | "#dce8f2", 38 | "#dbe8f2", 39 | "#dae7f1", 40 | "#d9e6f1", 41 | "#d8e6f0", 42 | "#d7e5f0", 43 | "#d6e4f0", 44 | "#d5e4ef", 45 | "#d4e3ef", 46 | "#d3e2ee", 47 | "#d2e1ee", 48 | "#d1e1ee", 49 | "#d0e0ed", 50 | "#cfdfed", 51 | "#cedeec", 52 | "#cddeec", 53 | "#ccddec", 54 | "#cbdceb", 55 | "#cadbeb", 56 | "#c9dbea", 57 | "#c8daea", 58 | "#c7d9ea", 59 | "#c6d8e9", 60 | "#c5d8e9", 61 | "#c4d7e8", 62 | "#c3d6e8", 63 | "#c2d5e7", 64 | "#c1d5e7", 65 | "#c0d4e7", 66 | "#bfd3e6", 67 | "#bed2e6", 68 | "#bdd2e5", 69 | "#bcd1e5", 70 | "#bbd0e5", 71 | "#bacfe4", 72 | "#b9cfe4", 73 | "#b8cee3", 74 | "#b7cde3", 75 | "#b5cce3", 76 | "#b4cce2", 77 | "#b3cbe2", 78 | "#b2cae1", 79 | "#b1c9e1", 80 | "#b0c9e1", 81 | "#afc8e0", 82 | "#afc7e0", 83 | "#aec6df", 84 | "#adc5df", 85 | "#acc5de", 86 | "#abc4de", 87 | "#aac3de", 88 | "#a9c2dd", 89 | "#a8c1dd", 90 | "#a7c0dc", 91 | "#a6c0dc", 92 | "#a5bfdb", 93 | "#a4bedb", 94 | "#a3bdda", 95 | "#a3bcda", 96 | "#a2bbd9", 97 | "#a1bad9", 98 | "#a0b9d8", 99 | "#9fb8d8", 100 | "#9fb7d7", 101 | "#9eb6d7", 102 | "#9db5d6", 103 | "#9cb4d6", 104 | "#9cb3d5", 105 | "#9bb2d5", 106 | "#9ab1d4", 107 | "#9ab0d4", 108 | "#99afd3", 109 | "#98aed3", 110 | "#98add2", 111 | "#97acd1", 112 | "#97aad1", 113 | "#96a9d0", 114 | "#95a8d0", 115 | "#95a7cf", 116 | "#94a6ce", 117 | "#94a5ce", 118 | "#93a3cd", 119 | "#93a2cc", 120 | "#92a1cc", 121 | "#92a0cb", 122 | "#929fcb", 123 | "#919dca", 124 | "#919cc9", 125 | "#909bc9", 126 | "#909ac8", 127 | "#9098c7", 128 | "#8f97c7", 129 | "#8f96c6", 130 | "#8f95c6", 131 | "#8f93c5", 132 | "#8e92c4", 133 | "#8e91c4", 134 | "#8e8fc3", 135 | "#8e8ec2", 136 | "#8e8dc2", 137 | "#8d8cc1", 138 | "#8d8ac0", 139 | "#8d89c0", 140 | "#8d88bf", 141 | "#8d86be", 142 | "#8d85be", 143 | "#8d84bd", 144 | "#8c82bc", 145 | "#8c81bc", 146 | "#8c80bb", 147 | "#8c7eba", 148 | "#8c7dba", 149 | "#8c7cb9", 150 | "#8c7ab9", 151 | "#8c79b8", 152 | "#8c78b7", 153 | "#8c76b7", 154 | "#8c75b6", 155 | "#8c74b5", 156 | "#8c72b5", 157 | "#8c71b4", 158 | "#8c70b3", 159 | "#8b6eb3", 160 | "#8b6db2", 161 | "#8b6cb1", 162 | "#8b6ab1", 163 | "#8b69b0", 164 | "#8b68af", 165 | "#8b66af", 166 | "#8b65ae", 167 | "#8b64ae", 168 | "#8b62ad", 169 | "#8b61ac", 170 | "#8b60ac", 171 | "#8b5eab", 172 | "#8a5daa", 173 | "#8a5caa", 174 | "#8a5aa9", 175 | "#8a59a8", 176 | "#8a58a8", 177 | "#8a56a7", 178 | "#8a55a6", 179 | "#8a54a6", 180 | "#8a52a5", 181 | "#8951a4", 182 | "#894fa3", 183 | "#894ea3", 184 | "#894da2", 185 | "#894ba1", 186 | "#894aa1", 187 | "#8949a0", 188 | "#88479f", 189 | "#88469e", 190 | "#88449d", 191 | "#88439d", 192 | "#88419c", 193 | "#88409b", 194 | "#873f9a", 195 | "#873d99", 196 | "#873c98", 197 | "#873a98", 198 | "#873997", 199 | "#863796", 200 | "#863695", 201 | "#863494", 202 | "#863393", 203 | "#853192", 204 | "#853091", 205 | "#852f90", 206 | "#852d8f", 207 | "#842c8e", 208 | "#842a8d", 209 | "#84298c", 210 | "#83278b", 211 | "#83268a", 212 | "#822589", 213 | "#822388", 214 | "#812287", 215 | "#812186", 216 | "#801f84", 217 | "#801e83", 218 | "#7f1d82", 219 | "#7e1c81", 220 | "#7e1a80", 221 | "#7d197f", 222 | "#7c187d", 223 | "#7b177c", 224 | "#7b167b", 225 | "#7a1579", 226 | "#791478", 227 | "#781377", 228 | "#771276", 229 | "#761174", 230 | "#741073", 231 | "#730f72", 232 | "#720f70", 233 | "#710e6f", 234 | "#700d6d", 235 | "#6e0c6c", 236 | "#6d0c6b", 237 | "#6c0b69", 238 | "#6a0a68", 239 | "#690a66", 240 | "#680965", 241 | "#660863", 242 | "#650862", 243 | "#630760", 244 | "#62075f", 245 | "#60065d", 246 | "#5f055c", 247 | "#5d055a", 248 | "#5c0459", 249 | "#5a0457", 250 | "#580356", 251 | "#570354", 252 | "#550253", 253 | "#540251", 254 | "#520150", 255 | "#50014e", 256 | "#4f004d", 257 | "#4d004b" 258 | ] -------------------------------------------------------------------------------- /data/coffee_shop.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "product": "Mint", 4 | "sales": 8342 5 | }, 6 | { 7 | "product": "Caffe Latte", 8 | "sales": 8665 9 | }, 10 | { 11 | "product": "Amaretto", 12 | "sales": 6781 13 | }, 14 | { 15 | "product": "Darjeeling", 16 | "sales": 17758 17 | }, 18 | { 19 | "product": "Decaf Espresso", 20 | "sales": 18888 21 | }, 22 | { 23 | "product": "Regular Espresso", 24 | "sales": 6744 25 | }, 26 | { 27 | "product": "Decaf Irish Cream", 28 | "sales": 14831 29 | }, 30 | { 31 | "product": "Colombian", 32 | "sales": 30761 33 | }, 34 | { 35 | "product": "Caffe Mocha", 36 | "sales": 21716 37 | } 38 | ] -------------------------------------------------------------------------------- /data/fruit.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "label" : "apples", 4 | "value" : 20 5 | }, 6 | { 7 | "label" : "bananas", 8 | "value" : 40 9 | }, 10 | { 11 | "label" : "pears", 12 | "value" : 30 13 | }, 14 | { 15 | "label" : "papaya", 16 | "value" : 50 17 | }, 18 | { 19 | "label" : "oranges", 20 | "value" : 70 21 | } 22 | 23 | ] -------------------------------------------------------------------------------- /data/historical_gdp.json: -------------------------------------------------------------------------------- 1 | [{"group": "USA", 2 | "year": "2012", 3 | "GDP": 16961 4 | }, 5 | {"group": "USA", 6 | "year": "2013", 7 | "GDP": 17273 8 | }, 9 | {"group": "USA", 10 | "year": "2014", 11 | "GDP": 17709 12 | }, 13 | {"group": "USA", 14 | "year": "2015", 15 | "GDP": 18238 16 | }, 17 | {"group": "USA", 18 | "year": "2016", 19 | "GDP": 18550 20 | }, 21 | {"group": "USA", 22 | "year": "2017", 23 | "GDP": 18978 24 | }, 25 | {"group": "USA", 26 | "year": "2018", 27 | "GDP": 19550 28 | }, 29 | {"group": "USA", 30 | "year": "2019", 31 | "GDP": 19968 32 | }, 33 | {"group": "USA", 34 | "year": "2020", 35 | "GDP": 19269 36 | }, 37 | {"group": "Developed Countries Less USA", 38 | "year": "2012", 39 | "GDP": 23909 40 | }, 41 | {"group": "Developed Countries Less USA", 42 | "year": "2013", 43 | "GDP": 24134 44 | }, 45 | {"group": "Developed Countries Less USA", 46 | "year": "2014", 47 | "GDP": 24536 48 | }, 49 | {"group": "Developed Countries Less USA", 50 | "year": "2015", 51 | "GDP": 25033 52 | }, 53 | {"group": "Developed Countries Less USA", 54 | "year": "2016", 55 | "GDP": 25467 56 | }, 57 | {"group": "Developed Countries Less USA", 58 | "year": "2017", 59 | "GDP": 26107 60 | }, 61 | {"group": "Developed Countries Less USA", 62 | "year": "2018", 63 | "GDP": 26574 64 | }, 65 | {"group": "Developed Countries Less USA", 66 | "year": "2019", 67 | "GDP": 26939 68 | }, 69 | {"group": "Developed Countries Less USA", 70 | "year": "2020", 71 | "GDP": 25350 72 | }, 73 | {"group": "Developing Countries", 74 | "year": "2012", 75 | "GDP": 25829 76 | }, 77 | {"group": "Developing Countries", 78 | "year": "2013", 79 | "GDP": 27212 80 | }, 81 | {"group": "Developing Countries", 82 | "year": "2014", 83 | "GDP": 28555 84 | }, 85 | {"group": "Developing Countries", 86 | "year": "2015", 87 | "GDP": 29821 88 | }, 89 | {"group": "Developing Countries", 90 | "year": "2016", 91 | "GDP": 31170 92 | }, 93 | {"group": "Developing Countries", 94 | "year": "2017", 95 | "GDP": 32697 96 | }, 97 | {"group": "Developing Countries", 98 | "year": "2018", 99 | "GDP": 34127 100 | }, 101 | {"group": "Developing Countries", 102 | "year": "2019", 103 | "GDP": 35391 104 | }, 105 | {"group": "Developing Countries", 106 | "year": "2020", 107 | "GDP": 34752 108 | } 109 | ] -------------------------------------------------------------------------------- /data/police-data.json: -------------------------------------------------------------------------------- 1 | [{"PdDistrict":"MISSION","DRUNKENNESS":82,"DISORDERLY CONDUCT":190,"TOTAL":"21163"},{"PdDistrict":"TENDERLOIN","DRUNKENNESS":79,"DISORDERLY CONDUCT":39,"TOTAL":"12737"},{"PdDistrict":"NORTHERN","DRUNKENNESS":55,"DISORDERLY CONDUCT":181,"TOTAL":"18975"},{"PdDistrict":"RICHMOND","DRUNKENNESS":25,"DISORDERLY CONDUCT":26,"TOTAL":"7692"},{"PdDistrict":"BAYVIEW","DRUNKENNESS":32,"DISORDERLY CONDUCT":76,"TOTAL":"15739"},{"PdDistrict":"CENTRAL","DRUNKENNESS":118,"DISORDERLY CONDUCT":109,"TOTAL":"13622"},{"PdDistrict":"PARK","DRUNKENNESS":73,"DISORDERLY CONDUCT":49,"TOTAL":"8219"},{"PdDistrict":"TARAVAL","DRUNKENNESS":20,"DISORDERLY CONDUCT":25,"TOTAL":"11329"},{"PdDistrict":"SOUTHERN","DRUNKENNESS":134,"DISORDERLY CONDUCT":157,"TOTAL":"25692"},{"PdDistrict":"INGLESIDE","DRUNKENNESS":44,"DISORDERLY CONDUCT":34,"TOTAL":"14008"}] -------------------------------------------------------------------------------- /data/sales.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "year": 198000, "efficiency": 24.3, "sales": 8949000 }, 3 | { "year": 198500, "efficiency": 27.6, "sales": 10979000 }, 4 | { "year": 199000, "efficiency": 28, "sales": 9303000 }, 5 | { "year": 199100, "efficiency": 28.4, "sales": 8185000 }, 6 | { "year": 199200, "efficiency": 27.9, "sales": 8213000 }, 7 | { "year": 199300, "efficiency": 28.4, "sales": 8518000 }, 8 | { "year": 199400, "efficiency": 28.3, "sales": 8991000 }, 9 | { "year": 199500, "efficiency": 28.6, "sales": 8620000 }, 10 | { "year": 199600, "efficiency": 28.5, "sales": 8479000 }, 11 | { "year": 199700, "efficiency": 28.7, "sales": 8217000 }, 12 | { "year": 199800, "efficiency": 28.8, "sales": 8085000 }, 13 | { "year": 199900, "efficiency": 28.3, "sales": 8638000 }, 14 | { "year": 200000, "efficiency": 28.5, "sales": 8778000 }, 15 | { "year": 200100, "efficiency": 28.8, "sales": 8352000 } 16 | ] 17 | -------------------------------------------------------------------------------- /data/skinny_fruit.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "date": "Thu Feb 01 2018 00:00:00 GMT-0500 (Eastern Standard Time)", 4 | "fruit": "Apples", 5 | "value": 10 6 | }, 7 | { 8 | "date": "Thu Mar 01 2018 00:00:00 GMT-0500 (Eastern Standard Time)", 9 | "fruit": "Apples", 10 | "value": 19 11 | }, 12 | { 13 | "date": "Thu Mar 08 2018 00:00:00 GMT-0500 (Eastern Standard Time)", 14 | "fruit": "Apples", 15 | "value": 38 16 | }, 17 | { 18 | "date": "Sun Apr 01 2018 00:00:00 GMT-0400 (Eastern Daylight Time)", 19 | "fruit": "Apples", 20 | "value": 20 21 | }, 22 | { 23 | "date": "Thu Feb 01 2018 00:00:00 GMT-0500 (Eastern Standard Time)", 24 | "fruit": "Oranges", 25 | "value": 13 26 | }, 27 | { 28 | "date": "Thu Mar 01 2018 00:00:00 GMT-0500 (Eastern Standard Time)", 29 | "fruit": "Oranges", 30 | "value": 20 31 | }, 32 | { 33 | "date": "Thu Mar 08 2018 00:00:00 GMT-0500 (Eastern Standard Time)", 34 | "fruit": "Oranges", 35 | "value": 4 36 | }, 37 | { 38 | "date": "Sun Apr 01 2018 00:00:00 GMT-0400 (Eastern Daylight Time)", 39 | "fruit": "Oranges", 40 | "value": 10 41 | }, 42 | { 43 | "date": "Thu Feb 01 2018 00:00:00 GMT-0500 (Eastern Standard Time)", 44 | "fruit": "Bananas", 45 | "value": 20 46 | }, 47 | { 48 | "date": "Thu Mar 01 2018 00:00:00 GMT-0500 (Eastern Standard Time)", 49 | "fruit": "Bananas", 50 | "value":15 51 | }, 52 | { 53 | "date": "Thu Mar 08 2018 00:00:00 GMT-0500 (Eastern Standard Time)", 54 | "fruit": "Bananas", 55 | "value": 6 56 | }, 57 | { 58 | "date": "Sun Apr 01 2018 00:00:00 GMT-0400 (Eastern Daylight Time)", 59 | "fruit": "Bananas", 60 | "value": 25 61 | }, 62 | { 63 | "date": "Thu Feb 01 2018 00:00:00 GMT-0500 (Eastern Standard Time)", 64 | "fruit": "Apricots", 65 | "value": 22 66 | }, 67 | { 68 | "date": "Thu Mar 01 2018 00:00:00 GMT-0500 (Eastern Standard Time)", 69 | "fruit": "Apricots", 70 | "value":3 71 | }, 72 | { 73 | "date": "Thu Mar 08 2018 00:00:00 GMT-0500 (Eastern Standard Time)", 74 | "fruit": "Apricots", 75 | "value": 3 76 | }, 77 | { 78 | "date": "Sun Apr 01 2018 00:00:00 GMT-0400 (Eastern Daylight Time)", 79 | "fruit": "Apricots", 80 | "value": 40 81 | } 82 | ] -------------------------------------------------------------------------------- /data/skinny_fruit_numbers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "date": 1, 4 | "fruit": "apples", 5 | "value": 10 6 | }, 7 | { 8 | "date": 2, 9 | "fruit": "apples", 10 | "value": 15 11 | }, 12 | { 13 | "date": 3, 14 | "fruit": "apples", 15 | "value": 38 16 | }, 17 | 18 | { 19 | "date": 1, 20 | "fruit": "oranges", 21 | "value": 15 22 | }, 23 | { 24 | "date": 2, 25 | "fruit": "oranges", 26 | "value": null 27 | }, 28 | { 29 | "date": 3, 30 | "fruit": "oranges", 31 | "value": 18 32 | }, 33 | 34 | { 35 | "date": 1, 36 | "fruit": "bananas", 37 | "value": 20 38 | }, 39 | { 40 | "date": 2, 41 | "fruit": "bananas", 42 | "value":15 43 | }, 44 | { 45 | "date": 3, 46 | "fruit": "bananas", 47 | "value": 6 48 | }, 49 | 50 | { 51 | "date": 1, 52 | "fruit": "apricots", 53 | "value": 22 54 | }, 55 | { 56 | "date": 2, 57 | "fruit": "apricots", 58 | "value":3 59 | }, 60 | { 61 | "date": 3, 62 | "fruit": "apricots", 63 | "value": 3 64 | } 65 | 66 | ] -------------------------------------------------------------------------------- /data/unemployment_discreet.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "division": "Bethesda-Rockville-Frederick, MD Met Div", 4 | "date": "2000-01-01T00:00:00.000Z", 5 | "unemployment": 2.6 6 | }, 7 | { 8 | "division": "Boston-Cambridge-Quincy, MA NECTA Div", 9 | "date": "2003-01-01T00:00:00.000Z", 10 | "unemployment": 5.3 11 | }, 12 | { 13 | "division": "Brockton-Bridgewater-Easton, MA NECTA Div", 14 | "date": "2005-10-01T00:00:00.000Z", 15 | "unemployment": 5.4 16 | }, 17 | { 18 | "division": "Camden, NJ Met Div", 19 | "date": "2008-01-01T00:00:00.000Z", 20 | "unemployment": 4.7 21 | }, 22 | { 23 | "division": "Chicago-Joliet-Naperville, IL Met Div", 24 | "date": "2005-11-01T00:00:00.000Z", 25 | "unemployment": 5.5 26 | } 27 | ] -------------------------------------------------------------------------------- /global-setup.js: -------------------------------------------------------------------------------- 1 | module.exports = async () => { 2 | process.env.TZ = 'UTC'; 3 | }; 4 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | import type {Config} from '@jest/types'; 3 | const config: Config.InitialOptions = { 4 | roots: ['./src','./tests'], 5 | preset: 'ts-jest', 6 | testEnvironment: 'jsdom', 7 | moduleFileExtensions: ['js', 'ts', 'tsx'], 8 | moduleNameMapper: { 9 | '^d3$': '/node_modules/d3/dist/d3.min.js', 10 | "\\.(css|less|scss|sss|styl)$": "/node_modules/jest-css-modules", 11 | }, 12 | globalSetup: "./tests/global-setup.js", 13 | 14 | }; 15 | 16 | export default config; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@oslabs-beta/d3reactor", 3 | "version": "0.0.13", 4 | "description": "Open-source charting library for creating performant, responsive data visualizations in React", 5 | "keywords": ["d3", "react", "chart", "graph", "svg", "visualization", "data visualization"], 6 | "main": "./dist/index.js", 7 | "types": "./dist/types/src/index.d.ts", 8 | "files": [ 9 | "dist/**/*" 10 | ], 11 | "sideEffects": [ 12 | "*.css" 13 | ], 14 | "scripts": { 15 | "build": "webpack --config webpack.prod.ts", 16 | "dev": "webpack-dev-server --config webpack.dev.ts --open", 17 | "cypress:open": "cypress open", 18 | "cypress:run": "cypress run", 19 | "test": "jest --config jest.config.ts", 20 | "test-cypress": "webpack-dev-server --config webpack.cypress.ts --open", 21 | "format": "prettier --write \"src/**/*.{ts,tsx}\"", 22 | "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\" --quiet" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/oslabs-beta/d3reactor.git" 27 | }, 28 | "author": "Robert Crocker robert@vizsimply.com, Dmytro Iershov idmytro@yahoo.com, Ece Ozalp eceiozalp@gmail.com, Travis Lockett TravisLockett1@gmail.com, Eric Mulhern eric.mulhern@gmail.com", 29 | "license": "ISC", 30 | "bugs": { 31 | "url": "https://github.com/oslabs-beta/d3reacts/issues" 32 | }, 33 | "homepage": "https://github.com/oslabs-beta/d3reacts#readme", 34 | "dependencies": { 35 | "@types/d3": "^7.1.0", 36 | "@types/styled-components": "5.1.21", 37 | "d3": "^7.1.1", 38 | "styled-components": "5.3.3" 39 | }, 40 | "devDependencies": { 41 | "@testing-library/jest-dom": "^5.16.1", 42 | "@testing-library/react": "^12.1.2", 43 | "@types/html-webpack-plugin": "^3.2.6", 44 | "@types/jest": "^27.4.0", 45 | "@types/node": "^16.11.21", 46 | "@types/react": "^17.0.34", 47 | "@types/react-dom": "^17.0.11", 48 | "@types/webpack": "^5.28.0", 49 | "@typescript-eslint/eslint-plugin": "5.9.1", 50 | "@typescript-eslint/parser": "5.9.1", 51 | "css-loader": "6.5.1", 52 | "cypress": "9.3.1", 53 | "dotenv": "^10.0.0", 54 | "eslint": "^8.2.0", 55 | "eslint-config-prettier": "8.3.0", 56 | "eslint-import-resolver-typescript": "2.5.0", 57 | "eslint-plugin-import": "2.25.4", 58 | "eslint-plugin-react": "7.28.0", 59 | "eslint-plugin-react-hooks": "4.3.0", 60 | "html-webpack-plugin": "^5.5.0", 61 | "jest": "^27.4.7", 62 | "jest-css-modules": "^2.1.0", 63 | "prettier": "2.5.1", 64 | "sass-loader": "^12.3.0", 65 | "style-loader": "3.3.1", 66 | "ts-jest": "^27.1.2", 67 | "ts-loader": "^9.2.6", 68 | "ts-node": "^10.4.0", 69 | "tsconfig-paths": "^3.11.0", 70 | "typescript": "^4.4.4", 71 | "webpack": "^5.64.0", 72 | "webpack-cli": "^4.9.1", 73 | "webpack-dev-server": "^4.5.0" 74 | }, 75 | "peerDependencies": { 76 | "react": "^17.0.2", 77 | "react-dom": "^17.0.2" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect } from 'react'; 2 | 3 | import BarChart from './charts/BarChart/BarChart'; 4 | import AreaChart from './charts/AreaChart/AreaChart'; 5 | import LineChart from './charts/LineChart/LineChart'; 6 | import ScatterPlot from './charts/ScatterPlot/ScatterPlot'; 7 | import PieChart from './charts/PieChart/PieChart'; 8 | import { Container } from './styles/componentStyles'; 9 | 10 | import portfolio from '../data/portfolio.json'; 11 | import penguins from '../data/penguins.json'; 12 | import fruit from '../data/fruit.json'; 13 | import unemployment from '../data/unemployment.json' 14 | import skinny_fruit from '../data/skinny_fruit.json'; 15 | 16 | 17 | function App() { 18 | const [pie, setPie] = useState(fruit.sort((a, b) => a.value - b.value).slice(2)) 19 | const [bar, setBar] = useState(skinny_fruit.reverse().slice(2)) 20 | const [area, setArea] = useState(portfolio.slice(30, 60)) 21 | const [line, setLine] = useState(unemployment.slice(0, 60)) 22 | const [scatter, setScatter] = useState(penguins.slice(30, 60)) 23 | 24 | useEffect(() => { 25 | setTimeout(() => {setPie(fruit.sort((a, b) => a.value - b.value))}, 1000); 26 | setTimeout(() => {setBar(skinny_fruit.reverse())}, 2000); 27 | setTimeout(() => {setArea(portfolio.slice(0, 60))}, 4000); 28 | setTimeout(() => {setLine(unemployment)}, 6000); 29 | setTimeout(() => {setScatter(penguins)}, 8000); 30 | }, []) 31 | return ( 32 | 33 | 41 | 57 | 70 | 87 | 105 | 106 | ); 107 | } 108 | 109 | export default App; 110 | -------------------------------------------------------------------------------- /src/charts/AreaChart/AreaChart.tsx: -------------------------------------------------------------------------------- 1 | /** App.js */ 2 | import React, { useState, useMemo } from 'react'; 3 | /*eslint import/namespace: ['error', { allowComputed: true }]*/ 4 | import * as d3 from 'd3'; 5 | import { 6 | Data, 7 | AreaChartProps, 8 | xAccessorFunc, 9 | yAccessorFunc, 10 | toolTipState, 11 | } from '../../../types'; 12 | import { Axis } from '../../components/ContinuousAxis'; 13 | import { Label } from '../../components/Label'; 14 | import { useResponsive } from '../../hooks/useResponsive'; 15 | import { xScaleDef } from '../../functionality/xScale'; 16 | import { yScaleDef } from '../../functionality/yScale'; 17 | import ListeningRect from '../../components/ListeningRect'; 18 | import Tooltip from '../../components/Tooltip'; 19 | import { ColorLegend } from '../../components/ColorLegend'; 20 | import { 21 | getXAxisCoordinates, 22 | getYAxisCoordinates, 23 | getMarginsWithLegend, 24 | inferXDataType, 25 | transformSkinnyToWide, 26 | EXTRA_LEGEND_MARGIN, 27 | themes, 28 | } from '../../utils'; 29 | import styled, { ThemeProvider } from 'styled-components'; 30 | 31 | const Area = styled.path` 32 | fill-opacity: 0.7; 33 | `; 34 | 35 | export default function AreaChart({ 36 | theme = 'light', 37 | data, 38 | height = '100%', 39 | width = '100%', 40 | xKey, 41 | yKey, 42 | xDataType, 43 | groupBy, 44 | xAxis = 'bottom', 45 | yAxis = 'left', 46 | xGrid = false, 47 | yGrid = false, 48 | xAxisLabel, 49 | yAxisLabel, 50 | legend, 51 | legendLabel = '', 52 | chartType = 'area-chart', 53 | colorScheme = 'schemePurples', 54 | tooltipVisible = true, 55 | }: AreaChartProps): JSX.Element { 56 | /********** 57 | Step in creating any chart: 58 | 1. Process data 59 | 2. Determine chart dimensions 60 | 3. Create scales 61 | 4. Define styles 62 | 5. Set up supportive elements 63 | 6. Set up interactions 64 | ***********/ 65 | 66 | // ******************** 67 | // STEP 1. Process data 68 | // Look at the data structure and declare how to access the values we'll need. 69 | // ******************** 70 | 71 | const xAccessor: xAccessorFunc = useMemo(() => { 72 | return xDataType === 'number' ? (d) => d[xKey] : (d) => new Date(d[xKey]); 73 | }, [xKey]); 74 | 75 | const yAccessor: yAccessorFunc = useMemo(() => { 76 | return (d) => d[yKey]; 77 | }, [yKey]); 78 | 79 | // if no xKey datatype is passed in, determine if it's Date 80 | if (!xDataType) { 81 | xDataType = inferXDataType(data[0], xKey); 82 | } 83 | 84 | // generate arr of keys. these are used to render discrete areas to be displayed 85 | const keys = useMemo(() => { 86 | const groupAccessor = (d: Data) => d[groupBy ?? '']; 87 | const groups = d3.group(data, groupAccessor); 88 | return groupBy ? Array.from(groups).map((group) => group[0]) : [yKey]; 89 | }, [groupBy, yKey, data]); 90 | 91 | const transData = useMemo(() => { 92 | return groupBy 93 | ? transformSkinnyToWide(data, keys, groupBy, xKey, yKey) 94 | : data; 95 | }, [data, keys, groupBy, xKey, yKey]); 96 | 97 | // generate stack: an array of Series representing the x and associated y0 & y1 values for each area 98 | const stack = d3.stack().keys(keys); 99 | const layers = useMemo(() => { 100 | const layersTemp = stack(transData as Iterable<{ [key: string]: number }>); 101 | for (const series of layersTemp) { 102 | series.sort((a, b) => b.data[xKey] - a.data[xKey]); 103 | } 104 | return layersTemp; 105 | }, [transData, keys]); 106 | 107 | // ******************** 108 | // STEP 2. Determine chart dimensions 109 | // Declare the physical (i.e. pixels) chart parameters 110 | // ******************** 111 | 112 | const { anchor, cHeight, cWidth } = useResponsive(); 113 | 114 | // width & height of legend, so we know how much to squeeze chart by 115 | const [legendOffset, setLegendOffset] = useState<[number, number]>([0, 0]); 116 | 117 | const [xOffset, yOffset] = legendOffset; 118 | 119 | const margin = useMemo( 120 | () => 121 | getMarginsWithLegend( 122 | xAxis, 123 | yAxis, 124 | xAxisLabel, 125 | yAxisLabel, 126 | legend, 127 | xOffset, 128 | yOffset, 129 | cWidth, 130 | cHeight 131 | ), 132 | [ 133 | xAxis, 134 | yAxis, 135 | xAxisLabel, 136 | yAxisLabel, 137 | legend, 138 | xOffset, 139 | yOffset, 140 | cWidth, 141 | cHeight, 142 | ] 143 | ); 144 | 145 | // offset group to match position of axes 146 | const translate = `translate(${margin.left}, ${margin.top})`; 147 | 148 | // ******************** 149 | // STEP 3. Create scales 150 | // Create scales for every data-to-pysical attribute in our chart 151 | // ******************** 152 | 153 | const { xScale, xMin, xMax } = useMemo(() => { 154 | return xScaleDef( 155 | transData, 156 | xDataType as 'number' | 'date', 157 | xAccessor, 158 | margin, 159 | cWidth, 160 | chartType 161 | ); 162 | }, [transData, xDataType, xAccessor, margin, cWidth, chartType]); 163 | 164 | const yScale = useMemo(() => { 165 | return yScaleDef( 166 | groupBy ? layers : transData, 167 | yAccessor, 168 | margin, 169 | cHeight, 170 | 'area-chart', 171 | groupBy 172 | ); 173 | }, [layers, transData, yAccessor, margin, cHeight, groupBy]); 174 | 175 | const areaGenerator: any = d3 176 | .area() 177 | .x((layer: any) => xScale(xAccessor(layer.data))) 178 | .y0((layer) => yScale(layer[0])) 179 | .y1((layer) => yScale(layer[1])); 180 | 181 | // ******************** 182 | // STEP 4. Define styles 183 | // Define how the data will drive your design 184 | // ******************** 185 | 186 | const numberOfKeys = Array.from(keys).length; 187 | const discreteColors = 188 | numberOfKeys < 4 ? 3 : Math.min(Array.from(keys).length, 9); 189 | const computedScheme = Array.from( 190 | d3[`${colorScheme}`][discreteColors] 191 | ).reverse(); 192 | const colorScale = d3.scaleOrdinal(computedScheme); 193 | colorScale.domain(keys); 194 | 195 | // ******************** 196 | // STEP 5. Set up supportive elements 197 | // Render your axes, labels, legends, annotations, etc. 198 | // ******************** 199 | 200 | const { xAxisX, xAxisY } = useMemo( 201 | () => getXAxisCoordinates(xAxis, cHeight, margin), 202 | [cHeight, xAxis, margin] 203 | ); 204 | const { yAxisX, yAxisY } = useMemo( 205 | () => getYAxisCoordinates(yAxis, cWidth, margin), 206 | [cWidth, yAxis, margin] 207 | ); 208 | 209 | const xTicksValue = [xMin, ...xScale.ticks(), xMax]; 210 | 211 | let labelArray = []; 212 | if (typeof groupBy === 'string' && groupBy.length !== 0) { 213 | labelArray = layers.map((layer: { key: any }) => layer.key); 214 | } else { 215 | labelArray = [yKey]; 216 | } 217 | 218 | // ******************** 219 | // STEP 6. Set up interactions 220 | // Initialize event listeners and create interaction behavior 221 | // ******************** 222 | 223 | const [tooltip, setTooltip] = useState(false); 224 | 225 | return ( 226 | 227 |
228 | {tooltipVisible && tooltip && ( 229 | 240 | )} 241 | 242 | 243 | {yAxis && ( 244 | 255 | )} 256 | {yAxisLabel && ( 257 | 268 | )} 269 | {xAxis && ( 270 | 282 | )} 283 | {xAxisLabel && ( 284 | 295 | )} 296 | {layers.map((layer, i) => ( 297 | 302 | ))} 303 | { 304 | // If legend prop is truthy, render legend component: 305 | legend && ( 306 | 321 | ) 322 | } 323 | 337 | 338 | 339 |
340 |
341 | ); 342 | } 343 | -------------------------------------------------------------------------------- /src/charts/BarChart/BarChart.tsx: -------------------------------------------------------------------------------- 1 | /** App.js */ 2 | import React, { useState, useMemo } from 'react'; 3 | /*eslint import/namespace: ['error', { allowComputed: true }]*/ 4 | import * as d3 from 'd3'; 5 | import { useResponsive } from '../../hooks/useResponsive'; 6 | import { Axis } from '../../components/ContinuousAxis'; 7 | import { DiscreteAxis } from '../../components/DiscreteAxis'; 8 | import { Rectangle } from '../../components/Rectangle'; 9 | import Tooltip from '../../components/Tooltip'; 10 | import { ColorLegend } from '../../components/ColorLegend'; 11 | import { transformSkinnyToWide } from '../../utils'; 12 | import { 13 | BarChartProps, 14 | Data, 15 | toolTipState, 16 | yAccessorFunc, 17 | } from '../../../types'; 18 | import { 19 | getXAxisCoordinates, 20 | getYAxisCoordinates, 21 | getMarginsWithLegend, 22 | EXTRA_LEGEND_MARGIN, 23 | themes, 24 | } from '../../utils'; 25 | import { yScaleDef } from '../../functionality/yScale'; 26 | import { Label } from '../../components/Label'; 27 | import{ ThemeProvider } from 'styled-components'; 28 | 29 | 30 | export default function BarChart({ 31 | theme = 'light', 32 | data, 33 | height = '100%', 34 | width = '100%', 35 | xKey, 36 | yKey, 37 | groupBy, 38 | xAxis = 'bottom', 39 | yAxis = 'left', 40 | yGrid = false, 41 | xAxisLabel, 42 | yAxisLabel, 43 | legend, 44 | legendLabel = '', 45 | chartType = 'bar-chart', 46 | colorScheme = 'schemePurples', 47 | tooltipVisible = true, 48 | }: BarChartProps): JSX.Element { 49 | /********** 50 | Step in creating any chart: 51 | 1. Process data 52 | 2. Determine chart dimensions 53 | 3. Create scales 54 | 4. Define styles 55 | 5. Set up supportive elements 56 | 6. Set up interactions 57 | ***********/ 58 | 59 | // ******************** 60 | // STEP 1. Process data 61 | // Look at the data structure and declare how to access the values we'll need. 62 | // ******************** 63 | 64 | const xAccessor: (d: Data) => string = useMemo(() => { 65 | return (d) => d[xKey]; 66 | }, []); 67 | 68 | const yAccessor: yAccessorFunc = useMemo(() => { 69 | return (d) => d[yKey]; 70 | }, []); 71 | 72 | // When the yKey key has been assigned to the groupBy variable we know the user didn't specify grouping 73 | const keys: string[] = useMemo(() => { 74 | const groupAccessor = (d: Data) => d[groupBy ?? '']; 75 | const groups = d3.group(data, groupAccessor); 76 | return groupBy ? Array.from(groups).map((group) => group[0]) : [yKey]; 77 | }, [groupBy, yKey, data]); 78 | 79 | const transData = useMemo(() => { 80 | return groupBy 81 | ? transformSkinnyToWide(data, keys, groupBy, xKey, yKey) 82 | : data; 83 | }, [data, keys, groupBy, xKey, yKey]); 84 | 85 | const stack = d3.stack().keys(keys).order(d3.stackOrderAscending); 86 | 87 | const layers = useMemo(() => { 88 | return stack(transData as Iterable<{ [key: string]: number }>); 89 | }, [transData]); 90 | 91 | const getSequenceData = (sequence: Data) => { 92 | const xKeyValue = { [xKey]: sequence.data[xKey] }; 93 | const yKeyValue = { [yKey]: sequence[1] }; 94 | return { ...xKeyValue, ...yKeyValue }; 95 | }; 96 | 97 | let labelArray = []; 98 | if (typeof groupBy === 'string' && groupBy.length !== 0) { 99 | labelArray = layers.map((layer: { key: any }) => layer.key); 100 | } else { 101 | labelArray = [yKey]; 102 | } 103 | 104 | // ******************** 105 | // STEP 2. Determine chart dimensions 106 | // Declare the physical (i.e. pixels) chart parameters 107 | // ******************** 108 | 109 | const { anchor, cHeight, cWidth } = useResponsive(); 110 | 111 | // width & height of legend, so we know how much to squeeze chart by 112 | const [legendOffset, setLegendOffset] = useState<[number, number]>([0, 0]); 113 | 114 | const [xOffset, yOffset] = legendOffset; 115 | 116 | const [tickMargin, setTickMargin] = useState(0); 117 | 118 | const margin = useMemo( 119 | () => 120 | getMarginsWithLegend( 121 | xAxis, 122 | yAxis, 123 | xAxisLabel, 124 | yAxisLabel, 125 | legend, 126 | xOffset, 127 | yOffset, 128 | cWidth, 129 | cHeight, 130 | tickMargin 131 | ), 132 | [ 133 | xAxis, 134 | yAxis, 135 | xAxisLabel, 136 | yAxisLabel, 137 | legend, 138 | xOffset, 139 | yOffset, 140 | cWidth, 141 | cHeight, 142 | tickMargin, 143 | ] 144 | ); 145 | const translate = `translate(${margin.left}, ${margin.top})`; 146 | 147 | // ******************** 148 | // STEP 3. Create scales 149 | // Create scales for every data-to-pysical attribute in our chart 150 | // ******************** 151 | 152 | const rangeMax = cWidth - margin.right - margin.left; 153 | 154 | const xScale = useMemo(() => { 155 | return d3 156 | .scaleBand() 157 | .paddingInner(0.1) 158 | .paddingOuter(0.1) 159 | .domain(data.map(xAccessor)) 160 | .range([0, rangeMax > 40 ? rangeMax : 40]); 161 | }, [transData, xAccessor, cWidth, margin]); 162 | 163 | const yScale = useMemo(() => { 164 | return yScaleDef( 165 | groupBy ? layers : transData, 166 | yAccessor, 167 | margin, 168 | cHeight, 169 | 'bar-chart', 170 | groupBy 171 | ); 172 | }, [transData, yAccessor, margin, cHeight, groupBy]); 173 | 174 | // ******************** 175 | // STEP 4. Define styles 176 | // Define how the data will drive your design 177 | // ******************** 178 | 179 | const numberOfKeys = Array.from(keys).length; 180 | const discreteColors = 181 | numberOfKeys < 4 ? 3 : Math.min(Array.from(keys).length, 9); 182 | const computedScheme = Array.from( 183 | d3[`${colorScheme}`][discreteColors] 184 | ).reverse(); 185 | const colorScale = d3.scaleOrdinal(computedScheme); 186 | colorScale.domain(keys); 187 | 188 | // ******************** 189 | // STEP 5. Set up supportive elements 190 | // Render your axes, labels, legends, annotations, etc. 191 | // ******************** 192 | 193 | const { xAxisX, xAxisY } = useMemo( 194 | () => getXAxisCoordinates(xAxis, cHeight, margin), 195 | [cHeight, xAxis, margin] 196 | ); 197 | 198 | const { yAxisX, yAxisY } = useMemo( 199 | () => getYAxisCoordinates(yAxis, cWidth, margin), 200 | [cWidth, yAxis, margin] 201 | ); 202 | 203 | // ******************** 204 | // STEP 6. Set up interactions 205 | // Initialize event listeners and create interaction behavior 206 | // ******************** 207 | 208 | const [tooltip, setTooltip] = useState(false); 209 | 210 | return ( 211 | 212 |
213 | {tooltipVisible && tooltip && ( 214 | 226 | )} 227 | 228 | 229 | {xAxis && ( 230 | 242 | )} 243 | {yAxisLabel && ( 244 | 256 | )} 257 | {yAxis && ( 258 | 269 | )} 270 | {xAxisLabel && ( 271 | 284 | )} 285 | {groupBy 286 | ? layers.map( 287 | ( 288 | layer: Data, 289 | i: number // MULTI CHART 290 | ) => ( 291 | 292 | {layer.map((sequence: Data, j: number) => ( 293 | 0 302 | ? yScale(sequence[0]) - yScale(sequence[1]) 303 | : 0 304 | } 305 | margin={margin} 306 | cWidth={cWidth} 307 | fill={colorScale(layer.key[i])} 308 | setTooltip={setTooltip} 309 | /> 310 | ))} 311 | 312 | ) 313 | ) 314 | : data.map((d: Data, i: number) => { 315 | return ( 316 | // SINGLE CHART 317 | 0 325 | ? yScale(yAccessor(d)) 326 | : yScale(0) 327 | } 328 | width={xScale.bandwidth()} 329 | height={ 330 | // draw rect from 0 mark to +value 331 | Math.abs(yScale(0) - yScale(yAccessor(d))) 332 | } 333 | margin={margin} 334 | fill={colorScale(yKey)} 335 | setTooltip={setTooltip} 336 | cWidth={cWidth} 337 | /> 338 | ); 339 | })} 340 | 341 | { 342 | // If legend prop is truthy, render legend component: 343 | legend && ( 344 | 360 | ) 361 | } 362 | 363 | 364 |
365 |
366 | ); 367 | } 368 | -------------------------------------------------------------------------------- /src/charts/LineChart/LineChart.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/restrict-plus-operands */ 2 | /** App.js */ 3 | import React, { useState, useMemo } from 'react'; 4 | import { useResponsive } from '../../hooks/useResponsive'; 5 | /*eslint import/namespace: ['error', { allowComputed: true }]*/ 6 | import * as d3 from 'd3'; 7 | import { Axis } from '../../components/ContinuousAxis'; 8 | import { Line } from '../../components/Line'; 9 | import { VoronoiWrapper } from '../../components/VoronoiWrapper'; 10 | import { LineChartProps, xAccessorFunc, yAccessorFunc } from '../../../types'; 11 | import { 12 | getXAxisCoordinates, 13 | getYAxisCoordinates, 14 | getMarginsWithLegend, 15 | inferXDataType, 16 | EXTRA_LEGEND_MARGIN, 17 | themes, 18 | } from '../../utils'; 19 | import { ColorLegend } from '../../components/ColorLegend'; 20 | import { yScaleDef } from '../../functionality/yScale'; 21 | import { xScaleDef } from '../../functionality/xScale'; 22 | import { d3Voronoi } from '../../functionality/voronoi'; 23 | import { Label } from '../../components/Label'; 24 | import Tooltip from '../../components/Tooltip'; 25 | 26 | import { ThemeProvider } from 'styled-components'; 27 | 28 | 29 | export default function LineChart({ 30 | theme = 'light', 31 | data, 32 | height = '100%', 33 | width = '100%', 34 | xKey, 35 | yKey, 36 | xDataType, 37 | groupBy, 38 | xAxis = 'bottom', 39 | yAxis = 'left', 40 | xGrid = false, 41 | yGrid = false, 42 | xAxisLabel, 43 | yAxisLabel, 44 | legend, 45 | legendLabel = '', 46 | chartType = 'line-chart', 47 | colorScheme = 'schemePurples', 48 | tooltipVisible = true, 49 | }: LineChartProps): JSX.Element { 50 | /********** 51 | Step in creating any chart: 52 | 1. Process data 53 | 2. Determine chart dimensions 54 | 3. Create scales 55 | 4. Define styles 56 | 5. Set up supportive elements 57 | 6. Set up interactions 58 | ***********/ 59 | 60 | // ******************** 61 | // STEP 1. Process data 62 | // Look at the data structure and declare how to access the values we'll need. 63 | // ******************** 64 | 65 | // if no xKey datatype is passed in, determine if it's Date 66 | let xType: 'number' | 'date' = inferXDataType(data[0], xKey); 67 | if (xDataType !== undefined) xType = xDataType; 68 | 69 | const xAccessor: xAccessorFunc = useMemo(() => { 70 | return xType === 'number' ? (d) => d[xKey] : (d) => new Date(d[xKey]); 71 | }, []); 72 | 73 | const yAccessor: yAccessorFunc = useMemo(() => { 74 | return (d) => d[yKey]; 75 | }, []); 76 | 77 | // Null values must be removed from the dataset so as to not break our the 78 | // Line generator function. 79 | const cleanData = useMemo(() => { 80 | return data.filter((el) => el[yKey] !== null); 81 | }, [data]); 82 | 83 | const lineGroups: any = d3.group(data, (d) => d[groupBy ?? '']); 84 | 85 | let keys: string[] = []; 86 | if (groupBy !== undefined) { 87 | keys = Array.from(lineGroups).map((group: any) => group[0]); 88 | } else { 89 | keys = [yKey]; 90 | } 91 | 92 | // ******************** 93 | // STEP 2. Determine chart dimensions 94 | // Declare the physical (i.e. pixels) chart parameters 95 | // ******************** 96 | 97 | // useResponsive is use to retrieve the height and width of the anchor element 98 | const { anchor, cHeight, cWidth } = useResponsive(); 99 | 100 | // width & height of legend, so we know how much to squeeze chart by 101 | const [legendOffset, setLegendOffset] = useState<[number, number]>([0, 0]); 102 | const [xOffset, yOffset] = legendOffset; 103 | 104 | const margin = useMemo( 105 | () => 106 | getMarginsWithLegend( 107 | xAxis, 108 | yAxis, 109 | xAxisLabel, 110 | yAxisLabel, 111 | legend, 112 | xOffset, 113 | yOffset, 114 | cWidth, 115 | cHeight 116 | ), 117 | [ 118 | xAxis, 119 | yAxis, 120 | xAxisLabel, 121 | yAxisLabel, 122 | legend, 123 | xOffset, 124 | yOffset, 125 | cWidth, 126 | cHeight, 127 | ] 128 | ); 129 | 130 | // With the margins we can now determine where the g element inside of our 131 | // SVG will be placed. So, we'll use margin to create our translation string 132 | const translate = `translate(${margin.left}, ${margin.top})`; 133 | 134 | // ******************** 135 | // STEP 3. Create scales 136 | // Create scales for every data-to-pysical attribute in our chart 137 | // ******************** 138 | 139 | const yScale = useMemo(() => { 140 | return yScaleDef(data, yAccessor, margin, cHeight, 'line-chart'); 141 | }, [data, yAccessor, margin, cHeight]); 142 | 143 | const { xScale, xMin, xMax } = useMemo(() => { 144 | return xScaleDef(data, xType, xAccessor, margin, cWidth, chartType); 145 | }, [data, cWidth, margin]); 146 | 147 | const line: any = d3 148 | .line() 149 | .curve(d3.curveLinear) 150 | .x((d) => xScale(xAccessor(d))) 151 | .y((d: any) => { 152 | return d[yKey] ? yScale(yAccessor(d)) : yScale(0); 153 | }); 154 | 155 | // ******************** 156 | // STEP 4. Define styles 157 | // Define how the data will drive your design 158 | // ******************** 159 | 160 | const numberOfKeys = Array.from(keys).length; 161 | const discreteColors = 162 | numberOfKeys < 4 ? 3 : Math.min(Array.from(keys).length, 9); 163 | const computedScheme = Array.from( 164 | d3[`${colorScheme}`][discreteColors] 165 | ).reverse(); 166 | const colorScale = d3.scaleOrdinal(computedScheme); 167 | colorScale.domain(computedScheme); 168 | 169 | // ******************** 170 | // STEP 5. Set up supportive elements 171 | // Render your axes, labels, legends, annotations, etc. 172 | // ******************** 173 | 174 | const { xAxisX, xAxisY } = useMemo( 175 | () => getXAxisCoordinates(xAxis, cHeight, margin), 176 | [cHeight, xAxis, margin] 177 | ); 178 | 179 | const { yAxisX, yAxisY } = useMemo( 180 | () => getYAxisCoordinates(yAxis, cWidth, margin), 181 | [cWidth, yAxis, margin] 182 | ); 183 | 184 | const xTicksValue = [xMin, ...xScale.ticks(), xMax]; 185 | 186 | // ******************** 187 | // STEP 6. Set up interactions 188 | // Initialize event listeners and create interaction behavior 189 | // ******************** 190 | 191 | const [tooltip, setTooltip] = useState(false); 192 | 193 | const voronoi = useMemo(() => { 194 | return d3Voronoi( 195 | data, 196 | xScale, 197 | yScale, 198 | xAccessor, 199 | yAccessor, 200 | cHeight, 201 | cWidth, 202 | margin 203 | ); 204 | }, [data, xScale, yScale, xAccessor, yAccessor, cHeight, cWidth, margin]); 205 | return ( 206 | 207 |
208 | {tooltipVisible && tooltip && ( 209 | 220 | )} 221 | 222 | 223 | {yAxis && ( 224 | 235 | )} 236 | {yAxisLabel && ( 237 | 248 | )} 249 | 250 | {xAxis && ( 251 | 263 | )} 264 | {xAxisLabel && ( 265 | 276 | )} 277 | {groupBy ? ( 278 | d3.map(lineGroups, (lineGroup: [string, []], i) => { 279 | return ( 280 | 286 | ); 287 | }) 288 | ) : ( 289 | 295 | )} 296 | {voronoi && ( 297 | 308 | )} 309 | 310 | { 311 | // If legend prop is truthy, render legend component: 312 | legend && ( 313 | 328 | ) 329 | } 330 | 331 | 332 |
333 |
334 | ); 335 | } 336 | -------------------------------------------------------------------------------- /src/charts/PieChart/PieChart.tsx: -------------------------------------------------------------------------------- 1 | /** App.js */ 2 | import React, { useState, useMemo } from 'react'; 3 | /*eslint import/namespace: ['error', { allowComputed: true }]*/ 4 | import * as d3 from 'd3'; 5 | import { useResponsive } from '../../hooks/useResponsive'; 6 | import { PieChartProps, Data } from '../../../types'; 7 | import { ColorLegend } from '../../components/ColorLegend'; 8 | import { Arc } from '../../components/Arc'; 9 | import Tooltip from '../../components/Tooltip'; 10 | import { 11 | checkRadiusDimension, 12 | calculateOuterRadius, 13 | getMarginsWithLegend, 14 | EXTRA_LEGEND_MARGIN, 15 | themes, 16 | } from '../../utils'; 17 | 18 | import styled, { ThemeProvider } from 'styled-components'; 19 | 20 | const PieLabel = styled.text` 21 | font-family: Tahoma, Geneva, Verdana, sans-serif; 22 | text-anchor: middle; 23 | alignment-baseline: middle; 24 | fill: black; 25 | pointer-events: none; 26 | `; 27 | 28 | export default function PieChart({ 29 | theme = 'light', 30 | data, 31 | innerRadius, 32 | label, 33 | legend, 34 | legendLabel, 35 | outerRadius, 36 | pieLabel, 37 | chartType = 'pie-chart', 38 | colorScheme = 'schemePurples', 39 | value, 40 | tooltipVisible = true, 41 | }: PieChartProps): JSX.Element { 42 | /********** 43 | Step in creating any chart: 44 | 1. Process data 45 | 2. Determine chart dimensions 46 | 3. Create scales 47 | 4. Define styles 48 | 5. Set up supportive elements 49 | 6. Set up interactions 50 | ***********/ 51 | 52 | // ******************** 53 | // STEP 1. Process data 54 | // Look at the data structure and declare how to access the values we'll need. 55 | // ******************** 56 | 57 | const keys = useMemo(() => { 58 | const groupAccessor = (d: Data) => d[label ?? '']; 59 | const groups: d3.InternMap = d3.group(data, groupAccessor); 60 | return Array.from(groups).map((group) => group[0]); 61 | }, [label, data]); 62 | 63 | // ******************** 64 | // STEP 2. Determine chart dimensions 65 | // Declare the physical (i.e. pixels) chart parameters 66 | // ******************** 67 | 68 | const { anchor, cHeight, cWidth } = useResponsive(); 69 | 70 | // width & height of legend, so we know how much to squeeze chart by 71 | const [legendOffset, setLegendOffset] = useState<[number, number]>([0, 0]); 72 | const xOffset = legendOffset[0]; 73 | const yOffset = legendOffset[1]; 74 | const margin = useMemo( 75 | () => 76 | getMarginsWithLegend( 77 | false, 78 | false, 79 | undefined, 80 | undefined, 81 | legend, 82 | xOffset, 83 | yOffset, 84 | cWidth, 85 | cHeight 86 | ), 87 | [legend, xOffset, yOffset, cWidth, cHeight] 88 | ); 89 | 90 | // ******************** 91 | // STEP 3. Create scales 92 | // Create scales for every data-to-pysical attribute in our chart 93 | // ******************** 94 | 95 | let ratio: number | undefined; 96 | 97 | if ( 98 | typeof outerRadius === 'number' && 99 | typeof innerRadius === 'number' && 100 | innerRadius !== 0 101 | ) { 102 | ratio = innerRadius / outerRadius; 103 | } 104 | 105 | outerRadius = outerRadius 106 | ? checkRadiusDimension(cHeight, cWidth, outerRadius, margin, legend) 107 | : calculateOuterRadius(cHeight, cWidth, margin); 108 | 109 | if (outerRadius < 20) outerRadius = 20; 110 | 111 | if (ratio) { 112 | innerRadius = ratio * outerRadius; 113 | } else if (innerRadius) { 114 | const checkedRadiusDimension = checkRadiusDimension( 115 | cHeight, 116 | cWidth, 117 | innerRadius, 118 | margin, 119 | legend 120 | ); 121 | innerRadius = checkedRadiusDimension > 0 ? checkedRadiusDimension : 0; 122 | } else innerRadius = 0; 123 | 124 | const arcGenerator: any = d3 125 | .arc() 126 | .innerRadius(innerRadius) 127 | .outerRadius(outerRadius); 128 | 129 | const pieGenerator: any = d3 130 | .pie() 131 | .padAngle(0) 132 | .value((d: any) => d[value]); 133 | 134 | const pie: any = pieGenerator(data); 135 | const propsData = useMemo( 136 | () => 137 | pie.map((d: any) => ({ [label]: d.data[label], [value]: d.data[value] })), 138 | [data] 139 | ); 140 | 141 | // ******************** 142 | // STEP 4. Define styles 143 | // Define how the data will drive your design 144 | // ******************** 145 | 146 | const numberOfKeys = Array.from(keys).length; 147 | const discreteColors = 148 | numberOfKeys < 4 ? 3 : Math.min(Array.from(keys).length, 9); 149 | const computedScheme = d3[`${colorScheme}`][discreteColors]; 150 | const colorScale = d3.scaleOrdinal(computedScheme); 151 | colorScale.domain(keys); 152 | 153 | // ******************** 154 | // STEP 5. Set up supportive elements 155 | // Render your axes, labels, legends, annotations, etc. 156 | // ******************** 157 | 158 | const colorLabels = [...keys].reverse(); 159 | 160 | const textTranform = (d: any) => { 161 | const [x, y]: number[] = arcGenerator.centroid(d); 162 | return `translate(${x}, ${y})`; 163 | }; 164 | 165 | // Position of the legend 166 | let xPosition = outerRadius + EXTRA_LEGEND_MARGIN; 167 | let yPosition = EXTRA_LEGEND_MARGIN; 168 | // Offset position of the pie 169 | let translateX = 0; 170 | let translateY = 0; 171 | 172 | switch (legend) { 173 | case 'top': 174 | xPosition = -xOffset / 2 + EXTRA_LEGEND_MARGIN; 175 | yPosition = -outerRadius - margin.top / 2 + EXTRA_LEGEND_MARGIN; 176 | translateY = yOffset; 177 | break; 178 | case 'bottom': 179 | xPosition = -xOffset / 2 + EXTRA_LEGEND_MARGIN; 180 | yPosition = outerRadius + margin.bottom / 2 - EXTRA_LEGEND_MARGIN; 181 | translateY = -yOffset; 182 | break; 183 | case 'left': 184 | xPosition = -outerRadius - margin.left + EXTRA_LEGEND_MARGIN; 185 | translateX = xOffset; 186 | break; 187 | case 'top-left': 188 | xPosition = -outerRadius - margin.left + EXTRA_LEGEND_MARGIN; 189 | yPosition = -outerRadius + margin.left / 2 + EXTRA_LEGEND_MARGIN; 190 | translateX = xOffset; 191 | break; 192 | case 'bottom-left': 193 | xPosition = -outerRadius - margin.left + EXTRA_LEGEND_MARGIN; 194 | yPosition = outerRadius - margin.left / 2 - EXTRA_LEGEND_MARGIN; 195 | translateX = xOffset; 196 | break; 197 | case 'left-top': 198 | xPosition = -outerRadius - margin.left + EXTRA_LEGEND_MARGIN; 199 | yPosition = -outerRadius - margin.top / 2 + EXTRA_LEGEND_MARGIN; 200 | translateY = yOffset; 201 | break; 202 | case 'left-bottom': 203 | xPosition = -outerRadius - margin.left + EXTRA_LEGEND_MARGIN; 204 | yPosition = outerRadius + margin.bottom / 2 - EXTRA_LEGEND_MARGIN; 205 | translateY = -yOffset; 206 | break; 207 | case 'right-top': 208 | xPosition = outerRadius - margin.top / 2 - EXTRA_LEGEND_MARGIN; 209 | yPosition = -outerRadius - margin.top / 2 + EXTRA_LEGEND_MARGIN; 210 | translateY = yOffset; 211 | break; 212 | case 'top-right': 213 | yPosition = -outerRadius + margin.right + EXTRA_LEGEND_MARGIN; 214 | translateX = -xOffset; 215 | break; 216 | case 'bottom-right': 217 | yPosition = outerRadius - margin.right + EXTRA_LEGEND_MARGIN; 218 | translateX = -xOffset; 219 | break; 220 | case 'right-bottom': 221 | xPosition = outerRadius - margin.bottom / 2 - EXTRA_LEGEND_MARGIN; 222 | yPosition = outerRadius + margin.bottom / 2 - EXTRA_LEGEND_MARGIN; 223 | translateY = -yOffset; 224 | break; 225 | case 'right': 226 | default: 227 | translateX = -xOffset; 228 | break; 229 | } 230 | 231 | const translate = `translate(${(cWidth + translateX) / 2}, ${ 232 | (cHeight + translateY) / 2 233 | })`; 234 | 235 | // ******************** 236 | // STEP 6. Set up interactions 237 | // Initialize event listeners and create interaction behavior 238 | // ******************** 239 | 240 | const [tooltip, setTooltip] = useState(false); 241 | 242 | return ( 243 | 244 |
245 | {tooltipVisible && tooltip && ( 246 | 258 | )} 259 | 260 | 261 | {pie.map((d: any, i: number) => ( 262 | 263 | 276 | {pieLabel && ( 277 | 281 | {d.data[value]} 282 | 283 | )} 284 | 285 | ))} 286 | { 287 | // If legend prop is truthy, render legend component: 288 | legend && ( 289 | 307 | ) 308 | } 309 | 310 | 311 |
312 |
313 | ); 314 | } 315 | -------------------------------------------------------------------------------- /src/charts/ScatterPlot/ScatterPlot.tsx: -------------------------------------------------------------------------------- 1 | /** App.js */ 2 | import React, { useState, useMemo } from 'react'; 3 | /*eslint import/namespace: ['error', { allowComputed: true }]*/ 4 | import * as d3 from 'd3'; 5 | import { useResponsive } from '../../hooks/useResponsive'; 6 | import { Axis } from '../../components/ContinuousAxis'; 7 | import { Circle } from '../../components/Circle'; 8 | import { ColorLegend } from '../../components/ColorLegend'; 9 | import { d3Voronoi } from '../../functionality/voronoi'; 10 | import { xScaleDef } from '../../functionality/xScale'; 11 | import { yScaleDef } from '../../functionality/yScale'; 12 | import { VoronoiWrapper } from '../../components/VoronoiWrapper'; 13 | import Tooltip from '../../components/Tooltip'; 14 | import { 15 | ScatterPlotProps, 16 | xAccessorFunc, 17 | yAccessorFunc, 18 | Data, 19 | toolTipState, 20 | } from '../../../types'; 21 | import { 22 | getXAxisCoordinates, 23 | getYAxisCoordinates, 24 | getMarginsWithLegend, 25 | inferXDataType, 26 | EXTRA_LEGEND_MARGIN, 27 | themes, 28 | } from '../../utils'; 29 | import { Label } from '../../components/Label'; 30 | 31 | import { ThemeProvider } from 'styled-components'; 32 | 33 | export default function ScatterPlot({ 34 | theme = 'light', 35 | data, 36 | height = '100%', 37 | width = '100%', 38 | xKey, 39 | xDataType, 40 | yKey, 41 | groupBy, 42 | xAxis = 'bottom', 43 | yAxis = 'left', 44 | xGrid = false, 45 | yGrid = false, 46 | xAxisLabel, 47 | yAxisLabel, 48 | legend, 49 | legendLabel = '', 50 | chartType = 'scatter-plot', 51 | colorScheme = 'schemePurples', 52 | tooltipVisible = true, 53 | }: ScatterPlotProps): JSX.Element { 54 | /********** 55 | Step in creating any chart: 56 | 1. Process data 57 | 2. Determine chart dimensions 58 | 3. Create scales 59 | 4. Define styles 60 | 5. Set up supportive elements 61 | 6. Set up interactions 62 | ***********/ 63 | 64 | // ******************** 65 | // STEP 1. Process data 66 | // Look at the data structure and declare how to access the values we'll need. 67 | // ******************** 68 | let xType: 'number' | 'date' = inferXDataType(data[0], xKey); 69 | if (xDataType !== undefined) xType = xDataType; 70 | 71 | const keys = useMemo(() => { 72 | const groupAccessor = (d: Data) => d[groupBy ?? '']; 73 | const groups = d3.group(data, groupAccessor); 74 | return groupBy ? Array.from(groups).map((group) => group[0]) : [yKey]; 75 | }, [groupBy, yKey, data]); 76 | 77 | const xAccessor: xAccessorFunc = useMemo(() => { 78 | return xType === 'number' ? (d) => d[xKey] : (d) => new Date(d[xKey]); 79 | }, []); 80 | 81 | const yAccessor: yAccessorFunc = useMemo(() => { 82 | return (d) => d[yKey]; 83 | }, []); 84 | 85 | // ******************** 86 | // STEP 2. Determine chart dimensions 87 | // Declare the physical (i.e. pixels) chart parameters 88 | // ******************** 89 | const { anchor, cHeight, cWidth } = useResponsive(); 90 | 91 | // width & height of legend, so we know how much to squeeze chart by 92 | const [legendOffset, setLegendOffset] = useState<[number, number]>([0, 0]); 93 | const xOffset = legendOffset[0]; 94 | const yOffset = legendOffset[1]; 95 | const margin = useMemo( 96 | () => 97 | getMarginsWithLegend( 98 | xAxis, 99 | yAxis, 100 | xAxisLabel, 101 | yAxisLabel, 102 | legend, 103 | xOffset, 104 | yOffset, 105 | cWidth, 106 | cHeight 107 | ), 108 | [ 109 | xAxis, 110 | yAxis, 111 | xAxisLabel, 112 | yAxisLabel, 113 | legend, 114 | xOffset, 115 | yOffset, 116 | cWidth, 117 | cHeight, 118 | ] 119 | ); 120 | 121 | const translate = `translate(${margin.left}, ${margin.top})`; 122 | 123 | // ******************** 124 | // STEP 3. Create scales 125 | // Create scales for every data-to-pysical attribute in our chart 126 | // ******************** 127 | 128 | const { xScale } = useMemo(() => { 129 | return xScaleDef(data, xType, xAccessor, margin, cWidth, chartType); 130 | }, [data, cWidth, margin]); 131 | 132 | const xAccessorScaled = (d: any) => xScale(xAccessor(d)); 133 | 134 | const yScale = useMemo(() => { 135 | return yScaleDef(data, yAccessor, margin, cHeight, 'scatter-plot'); 136 | }, [data, yAccessor, margin, cHeight]); 137 | 138 | const yAccessorScaled = (d: any) => yScale(yAccessor(d)); 139 | // ******************** 140 | // STEP 4. Define styles 141 | // Define how the data will drive your design 142 | // ******************** 143 | 144 | // discreteColors must be between 3 and 9, so here we create a range. 145 | const numberOfKeys = Array.from(keys).length; 146 | const discreteColors = 147 | numberOfKeys < 4 ? 3 : Math.min(Array.from(keys).length, 9); 148 | const computedScheme = Array.from( 149 | d3[`${colorScheme}`][discreteColors] 150 | ).reverse(); 151 | const colorScale = d3.scaleOrdinal(computedScheme); 152 | colorScale.domain(keys); 153 | 154 | // ******************** 155 | // STEP 5. Set up supportive elements 156 | // Render your axes, labels, legends, annotations, etc. 157 | // ******************** 158 | 159 | const { xAxisX, xAxisY } = useMemo( 160 | () => getXAxisCoordinates(xAxis, cHeight, margin), 161 | [cHeight, xAxis, margin] 162 | ); 163 | 164 | const { yAxisX, yAxisY } = useMemo( 165 | () => getYAxisCoordinates(yAxis, cWidth, margin), 166 | [cWidth, yAxis, margin] 167 | ); 168 | 169 | // ******************** 170 | // STEP 6. Set up interactions 171 | // Initialize event listeners and create interaction behavior 172 | // ******************** 173 | 174 | const [tooltip, setTooltip] = useState(false); 175 | 176 | const voronoi = useMemo(() => { 177 | return d3Voronoi( 178 | data, 179 | xScale, 180 | yScale, 181 | xAccessor, 182 | yAccessor, 183 | cHeight, 184 | cWidth, 185 | margin 186 | ); 187 | }, [data, xScale, yScale, xAccessor, yAccessor, cHeight, cWidth, margin]); 188 | 189 | return ( 190 | 191 |
192 | {tooltipVisible && tooltip && ( 193 | 205 | )} 206 | 207 | 208 | {yAxis && ( 209 | 221 | )} 222 | {yAxisLabel && ( 223 | 234 | )} 235 | {xAxis && ( 236 | 247 | )} 248 | {xAxisLabel && ( 249 | 260 | )} 261 | {data.map((element: any, i: number) => 262 | !groupBy ? ( 263 | 270 | ) : ( 271 | 278 | ) 279 | )} 280 | {voronoi && ( 281 | 292 | )} 293 | 294 | { 295 | // If legend prop is truthy, render legend component: 296 | legend && ( 297 | 312 | ) 313 | } 314 | 315 | 316 |
317 |
318 | ); 319 | } 320 | -------------------------------------------------------------------------------- /src/components/Arc.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/restrict-plus-operands */ 2 | import React from 'react'; 3 | 4 | import { ArcProps } from '../../types'; 5 | 6 | export const Arc = React.memo( 7 | ({ 8 | data, 9 | dataTestId = 'arc', 10 | fill = 'none', 11 | stroke, 12 | strokeWidth = '1px', 13 | d, 14 | setTooltip, 15 | margin, 16 | cWidth, 17 | }: ArcProps): JSX.Element => { 18 | let tooltipState = { 19 | cursorX: 0, 20 | cursorY: 0, 21 | distanceFromTop: 0, 22 | distanceFromRight: 0, 23 | distanceFromLeft: 0, 24 | data, 25 | }; 26 | const onMouseMove = (e: any) => { 27 | if (setTooltip) { 28 | tooltipState = { 29 | cursorX: e.nativeEvent.pageX, 30 | cursorY: e.nativeEvent.pageY, 31 | distanceFromTop: e.nativeEvent.pageY - e.clientY + e.clientY, 32 | distanceFromRight: 33 | cWidth - (margin.right + margin.left) - e.nativeEvent.pageX, 34 | distanceFromLeft: e.pageX, 35 | data, 36 | }; 37 | 38 | setTooltip(tooltipState); 39 | } 40 | }; 41 | 42 | const onMouseLeave = () => { 43 | if (setTooltip) { 44 | setTooltip ? setTooltip(false) : null; 45 | } 46 | }; 47 | 48 | return ( 49 | onMouseMove(e)} 57 | onMouseLeave={() => onMouseLeave()} 58 | /> 59 | ); 60 | } 61 | ); 62 | -------------------------------------------------------------------------------- /src/components/Circle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { CircleProps } from '../../types'; 3 | 4 | import styled from 'styled-components'; 5 | 6 | const CircleComp = styled.circle` 7 | fill-opacity: 0.7; 8 | stroke-width: 1.4; 9 | `; 10 | 11 | export const Circle = React.memo( 12 | ({ cx, cy, r = '4', color }: CircleProps): JSX.Element => { 13 | return ( 14 | 22 | ); 23 | } 24 | ); 25 | -------------------------------------------------------------------------------- /src/components/ColorLegend.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { ColorLegendProps } from '../../types'; 3 | import styled from 'styled-components'; 4 | 5 | const Legend = styled.div` 6 | box-sizing: border-box; 7 | width: 100%; 8 | height: 100%; 9 | border: ${(props) => props.theme.legendBorder}; 10 | border-radius: 4px; 11 | white-space: nowrap; 12 | font-family: Tahoma, Geneva, Verdana, sans-serif; 13 | background-color: ${(props) => props.theme.legendBackgroundColor}; 14 | `; 15 | 16 | const LegendTitle = styled.text` 17 | font-size: 14px; 18 | font-family: Tahoma, Geneva, Verdana, sans-serif; 19 | fill: ${(props) => props.theme.legendTextColor}; 20 | `; 21 | 22 | const LegendLabel = styled.text` 23 | font-size: 12px; 24 | font-family: Tahoma, Geneva, Verdana, sans-serif; 25 | fill: ${(props) => props.theme.legendTextColor}; 26 | `; 27 | 28 | export const ColorLegend = ({ 29 | colorScale, 30 | circleRadius = 10, 31 | labels, 32 | dataTestId = 'color-legend', 33 | tickSpacing = circleRadius * 2 + 6, 34 | tickTextOffset = circleRadius * 1.2 + 3, 35 | legendLabel = '', 36 | legendPosition, 37 | legendWidth, 38 | legendHeight, 39 | setLegendOffset, 40 | margin, // margin of chart 41 | xAxisPosition = false, 42 | yAxisPosition = false, 43 | cWidth, 44 | cHeight, 45 | EXTRA_LEGEND_MARGIN = 6, 46 | fontSize = 14, 47 | xPosition, 48 | yPosition, 49 | }: ColorLegendProps) => { 50 | const domain = colorScale.domain(); 51 | // Make space for legend label 52 | let longestWord: number; 53 | let labelHeightOffset: number; 54 | if (legendLabel) { 55 | longestWord = legendLabel.length; 56 | labelHeightOffset = 1.5; 57 | } else { 58 | longestWord = 0; 59 | labelHeightOffset = 0; 60 | } 61 | 62 | // determine legend placement for any chart except pie, 63 | // or if no manual legend coordinates are passed in 64 | if (!xPosition && !yPosition) { 65 | let correctForAxis = 0; 66 | xPosition = 0; 67 | yPosition = cHeight / 2 - legendHeight / 2; 68 | switch (legendPosition) { 69 | case 'top': 70 | xPosition = (cWidth - margin.left - margin.right) / 2 - legendWidth / 2; 71 | yPosition = legendHeight / 2 - margin.top + EXTRA_LEGEND_MARGIN; 72 | break; 73 | case 'left-top': 74 | xPosition = EXTRA_LEGEND_MARGIN - margin.left; 75 | yPosition = legendHeight / 2 - margin.top + EXTRA_LEGEND_MARGIN; 76 | break; 77 | case 'right-top': 78 | xPosition = 79 | cWidth - 80 | margin.left - 81 | margin.right - 82 | legendWidth - 83 | EXTRA_LEGEND_MARGIN + 84 | 20; 85 | yPosition = legendHeight / 2 - margin.top + EXTRA_LEGEND_MARGIN; 86 | break; 87 | case 'bottom': 88 | xPosition = (cWidth - margin.left - margin.right) / 2 - legendWidth / 2; 89 | correctForAxis = xAxisPosition === 'top' ? margin.bottom : margin.top; 90 | yPosition = 91 | cHeight - legendHeight / 2 - correctForAxis - EXTRA_LEGEND_MARGIN; 92 | break; 93 | case 'left-bottom': 94 | xPosition = EXTRA_LEGEND_MARGIN - margin.left; 95 | correctForAxis = xAxisPosition === 'top' ? margin.bottom : margin.top; 96 | yPosition = 97 | cHeight - legendHeight / 2 - correctForAxis - EXTRA_LEGEND_MARGIN; 98 | break; 99 | case 'right-bottom': 100 | xPosition = 101 | cWidth - 102 | margin.left - 103 | margin.right - 104 | legendWidth - 105 | EXTRA_LEGEND_MARGIN + 106 | 20; 107 | correctForAxis = xAxisPosition === 'top' ? margin.bottom : margin.top; 108 | yPosition = 109 | cHeight - legendHeight / 2 - correctForAxis - EXTRA_LEGEND_MARGIN; 110 | break; 111 | case 'left': 112 | xPosition = -margin.left + EXTRA_LEGEND_MARGIN; 113 | break; 114 | case 'top-left': 115 | xPosition = -margin.left + EXTRA_LEGEND_MARGIN; 116 | yPosition = legendHeight / 2 - margin.top + EXTRA_LEGEND_MARGIN; 117 | break; 118 | case 'bottom-left': 119 | xPosition = -margin.left + EXTRA_LEGEND_MARGIN; 120 | correctForAxis = xAxisPosition === 'top' ? margin.bottom : margin.top; 121 | yPosition = 122 | cHeight - legendHeight / 2 - correctForAxis - EXTRA_LEGEND_MARGIN; 123 | break; 124 | case 'top-right': 125 | correctForAxis = yAxisPosition === 'left' ? margin.right : margin.left; 126 | xPosition = cWidth - legendWidth - correctForAxis - EXTRA_LEGEND_MARGIN; 127 | yPosition = legendHeight / 2 - margin.top + EXTRA_LEGEND_MARGIN; 128 | break; 129 | case 'bottom-right': 130 | correctForAxis = yAxisPosition === 'left' ? margin.right : margin.left; 131 | xPosition = cWidth - legendWidth - correctForAxis - EXTRA_LEGEND_MARGIN; 132 | correctForAxis = xAxisPosition === 'top' ? margin.bottom : margin.top; 133 | yPosition = 134 | cHeight - legendHeight / 2 - correctForAxis - EXTRA_LEGEND_MARGIN; 135 | break; 136 | case 'right': 137 | default: 138 | correctForAxis = yAxisPosition === 'left' ? margin.right : margin.left; 139 | xPosition = cWidth - legendWidth - correctForAxis - EXTRA_LEGEND_MARGIN; 140 | } 141 | } 142 | 143 | const rectHeight = 144 | tickSpacing * (labels.length + labelHeightOffset) + EXTRA_LEGEND_MARGIN * 2; 145 | // trying to make the legend no taller than the chart: 146 | // const rectHeight = Math.min(tickSpacing*(domain.length + labelHeightOffset) + EXTRA_LEGEND_MARGIN*2, cHeight); 147 | 148 | // iterate thru category names, create color swab & text for each 149 | const legend = labels.map((domainValue: string, i: number) => { 150 | if (domainValue.length > longestWord) longestWord = domainValue.length; 151 | return ( 152 | 164 | 165 | 166 | {domainValue} 167 | 168 | 169 | ); 170 | }); 171 | 172 | const rectWidth = 173 | tickTextOffset + 174 | circleRadius * 2 + 175 | (longestWord * (fontSize + 1)) / 2 + 176 | EXTRA_LEGEND_MARGIN * 2; //+1 by fontSize is a bit of a kludge 177 | 178 | useEffect(() => setLegendOffset([rectWidth, rectHeight]), [rectWidth, rectHeight]); 179 | 180 | return ( 181 | 185 | 0 ? rectWidth : 20} 189 | height={rectHeight > 0 ? rectHeight : 20} 190 | pointerEvents="none" 191 | // style={fill: 'red'} 192 | > 193 | 194 | 195 | 196 | 206 | {legendLabel} 207 | 208 | {legend} 209 | 210 | ); 211 | }; 212 | -------------------------------------------------------------------------------- /src/components/ContinuousAxis.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as d3 from 'd3'; 3 | import { ContinuousAxisProps } from '../../types'; 4 | import { gridGenerator } from '../functionality/grid'; 5 | 6 | import styled from 'styled-components'; 7 | 8 | const TickText = styled.text` 9 | font-size: 12px; 10 | font-family: Tahoma, Geneva, Verdana, sans-serif; 11 | fill: ${(props) => props.theme.textColor}; 12 | `; 13 | 14 | const AxisBaseline = styled.line` 15 | stroke: ${(props) => props.theme.axisBaseLineColor}; 16 | stroke-width: 2; 17 | `; 18 | 19 | function Axi({ 20 | theme = 'light', 21 | dataTestId = 'd3reactor-continuous', 22 | x, 23 | y, 24 | scale, 25 | type, 26 | width, 27 | height, 28 | margin, 29 | xGrid, 30 | yGrid, 31 | xTicksValue, 32 | chartType, 33 | }: ContinuousAxisProps): JSX.Element { 34 | let x1 = 0, 35 | y1 = 0, 36 | x2 = 0, 37 | y2 = 0; 38 | switch (type) { 39 | case 'bottom': 40 | x1 = x; 41 | y1 = y; 42 | x2 = width - margin.right - margin.left; 43 | if (x2 < 40) x2 = 40; 44 | y2 = y; 45 | break; 46 | case 'top': 47 | x1 = x; 48 | y1 = y; 49 | x2 = width - margin.right - margin.left; 50 | if (x2 < 40) x2 = 40; 51 | y2 = y; 52 | break; 53 | case 'left': 54 | x1 = x; 55 | y1 = 0; 56 | x2 = x; 57 | y2 = height - margin.top - margin.bottom; 58 | if (y2 < 40) y2 = 40; 59 | break; 60 | case 'right': 61 | x1 = x; 62 | y1 = y; 63 | x2 = x; 64 | y2 = height - margin.top - margin.bottom; 65 | if (y2 < 40) y2 = 40; 66 | break; 67 | default: 68 | x1 = 0; 69 | y1 = 0; 70 | x2 = 0; 71 | y2 = 0; 72 | break; 73 | } 74 | 75 | const getTickTranslation = ( 76 | axisType: string, 77 | individualTick: number | Date 78 | ): string => { 79 | switch (axisType) { 80 | case 'top': 81 | return `translate(${scale(individualTick)}, ${y - 8})`; 82 | case 'right': 83 | return `translate(${x + 12}, ${scale(individualTick)})`; 84 | case 'bottom': 85 | return `translate(${scale(individualTick)}, ${y + 18})`; 86 | case 'left': 87 | return `translate(${x - 12}, ${scale(individualTick)})`; 88 | default: 89 | return `translate(0,0)`; 90 | } 91 | }; 92 | 93 | const getTickStyle = (axisType: string): any => { 94 | // TODO remove any 95 | switch (axisType) { 96 | case 'top': 97 | return { textAnchor: 'middle', dominantBaseline: 'auto' }; 98 | case 'right': 99 | return { textAnchor: 'start', dominantBaseline: 'middle' }; 100 | case 'bottom': 101 | return { textAnchor: 'middle', dominantBaseline: 'auto' }; 102 | case 'left': 103 | return { textAnchor: 'end', dominantBaseline: 'middle' }; 104 | } 105 | }; 106 | 107 | const grid = gridGenerator( 108 | theme, 109 | type, 110 | xGrid, 111 | yGrid, 112 | xTicksValue, 113 | scale, 114 | height, 115 | width, 116 | margin 117 | ); 118 | 119 | let numberOfHorizontalTicks: number; 120 | if (width < 480) { 121 | numberOfHorizontalTicks = width / 100; 122 | } else if (width < 769) { 123 | numberOfHorizontalTicks = width / 120; 124 | } else if (width < 1024) { 125 | numberOfHorizontalTicks = width / 140; 126 | } else { 127 | numberOfHorizontalTicks = width / 160; 128 | } 129 | 130 | const numberOfVerticalTicks: number = height / 100; 131 | const horizontalTicks = scale.ticks(numberOfHorizontalTicks); 132 | const verticalTicks = scale.ticks(numberOfVerticalTicks); 133 | 134 | const formatTick = d3.timeFormat('%x'); 135 | 136 | const getFormattedTick = (individualTick: number | Date) => { 137 | if (typeof individualTick === 'number') { 138 | return individualTick; 139 | } else { 140 | return formatTick(individualTick); 141 | } 142 | }; 143 | 144 | return ( 145 | 146 | {grid} 147 | {(type === 'top' || 148 | type === 'bottom' || 149 | chartType === 'scatter-plot') && ( 150 | 157 | )} 158 | {(type === 'top' || type === 'bottom') && 159 | horizontalTicks.map((tick, i) => ( 160 | 166 | {getFormattedTick(tick)} 167 | 168 | ))} 169 | {(type === 'right' || type === 'left') && 170 | verticalTicks.map((tick, i) => ( 171 | 177 | {getFormattedTick(tick)} 178 | 179 | ))} 180 | 181 | ); 182 | } 183 | 184 | function AxisPropsAreEqual( 185 | prevAxis: ContinuousAxisProps, 186 | newAxis: ContinuousAxisProps 187 | ) { 188 | return ( 189 | prevAxis.scale === newAxis.scale && 190 | prevAxis.height === newAxis.height && 191 | prevAxis.width === newAxis.width 192 | ); 193 | } 194 | 195 | export const Axis = React.memo(Axi, AxisPropsAreEqual); 196 | -------------------------------------------------------------------------------- /src/components/DiscreteAxis.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import * as d3 from 'd3'; 3 | import { DiscreteAxisProps, Data } from '../../types'; 4 | 5 | import styled from 'styled-components'; 6 | 7 | const TickText = styled.text` 8 | font-size: 12px; 9 | font-family: Tahoma, Geneva, Verdana, sans-serif; 10 | fill: ${(props) => props.theme.textColor}; 11 | `; 12 | 13 | const AxisBaseline = styled.line` 14 | stroke: ${(props) => props.theme.axisBaseLineColor}; 15 | stroke-width: 2; 16 | `; 17 | 18 | export const DiscreteAxis = React.memo( 19 | ({ 20 | dataTestId = 'discrete-axis', 21 | x, 22 | y, 23 | scale, 24 | type, 25 | width, 26 | margin, 27 | data, 28 | xAccessor, 29 | setTickMargin, 30 | }: DiscreteAxisProps): JSX.Element => { 31 | const fontSize = 7; 32 | let x1 = 0, 33 | y1 = 0, 34 | x2 = 0, 35 | y2 = 0; 36 | switch (type) { 37 | case 'bottom': 38 | x1 = x; 39 | y1 = y; 40 | x2 = width - margin.right - margin.left; 41 | y2 = y; 42 | break; 43 | case 'top': 44 | x1 = x; 45 | y1 = y; 46 | x2 = width - margin.right - margin.left; 47 | y2 = y; 48 | break; 49 | default: 50 | x1 = 0; 51 | y1 = 0; 52 | x2 = 0; 53 | y2 = 0; 54 | break; 55 | } 56 | 57 | const formatTick = d3.timeFormat('%x'); 58 | const getFormattedTick = (individualTick: string) => { 59 | if (individualTick.length > 10 && !isNaN(Date.parse(individualTick))) { 60 | return formatTick(new Date(individualTick)); 61 | } else { 62 | return individualTick; 63 | } 64 | }; 65 | 66 | const ticksOriginal = data.map((d) => xAccessor(d)); 67 | const ticks = data.map((d) => getFormattedTick(xAccessor(d))); 68 | const check = ticks.some((tick) => tick.length * 8 > scale.bandwidth()); 69 | const longestTick = ticks.reduce((a, b) => (a.length > b.length ? a : b)); 70 | 71 | useEffect(() => { 72 | check 73 | ? setTickMargin( 74 | longestTick.length < 10 ? (longestTick.length * fontSize) / 2 : 40 75 | ) 76 | : setTickMargin(0); 77 | }, [check]); 78 | 79 | const getTickTranslation = ( 80 | axisType: string, 81 | individualTick: string, 82 | i: number 83 | ): string => { 84 | switch (axisType) { 85 | case 'top': 86 | return check 87 | ? `translate(${ 88 | scale.bandwidth() / 2 + (scale(ticksOriginal[i]) ?? 0) 89 | }, ${y - fontSize})` 90 | : `translate(${ 91 | scale.bandwidth() / 2 + (scale(ticksOriginal[i]) ?? 0) 92 | }, ${y - fontSize})`; 93 | case 'bottom': 94 | return check 95 | ? `translate(${ 96 | scale.bandwidth() / 2 + 97 | (scale(ticksOriginal[i]) ?? 0) + 98 | fontSize / 2 99 | }, ${y + (individualTick.length / 2) * fontSize}), rotate(-90)` 100 | : `translate(${ 101 | scale.bandwidth() / 2 + (scale(ticksOriginal[i]) ?? 0) 102 | }, ${y + fontSize * 2})`; 103 | default: 104 | return `translate(0,0)`; 105 | } 106 | }; 107 | const getTickStyle = ( 108 | axisType: string, 109 | individualTick: Data 110 | ): { [key: string]: string } | undefined => { 111 | switch (axisType) { 112 | case 'top': 113 | return { textAnchor: 'middle', dominantBaseline: 'auto' }; 114 | case 'bottom': 115 | return { textAnchor: 'middle', dominantBaseline: 'auto' }; 116 | } 117 | }; 118 | return ( 119 | 120 | 127 | {!check && ( 128 | <> 129 | {ticks.map((tick: any, i: number) => ( 130 | 136 | {tick} 137 | 138 | ))} 139 | 140 | )} 141 | {check && ( 142 | <> 143 | {ticks.map((tick: any, i: number) => ( 144 | 150 | {tick.slice(0, 10)} 151 | 152 | ))} 153 | 154 | )} 155 | 156 | ); 157 | } 158 | ); 159 | -------------------------------------------------------------------------------- /src/components/Label.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { getAxisLabelCoordinates } from '../utils'; 3 | import { Margin } from '../../types'; 4 | 5 | import styled from 'styled-components'; 6 | const AxisLabel = styled.text` 7 | font-size: 16px; 8 | font-family: Tahoma, Geneva, Verdana, sans-serif; 9 | fill: ${(props) => props.theme.textColor}; 10 | `; 11 | 12 | export function Label({ 13 | theme = 'light', 14 | dataTestId = 'label', 15 | x, 16 | y, 17 | height, 18 | width, 19 | margin, 20 | type, 21 | axis, 22 | label, 23 | tickMargin, 24 | }: { 25 | theme: string; 26 | dataTestId?: string; 27 | x: number; 28 | y: number; 29 | height: number; 30 | width: number; 31 | margin: Margin; 32 | type: string; 33 | axis: boolean; 34 | label: string; 35 | tickMargin?: number; 36 | }): JSX.Element { 37 | const { axisLabelX, axisLabelY, rotate } = useMemo( 38 | () => 39 | getAxisLabelCoordinates( 40 | x, 41 | y, 42 | height, 43 | width, 44 | margin, 45 | type, 46 | axis, 47 | tickMargin 48 | ), 49 | [x, y, width, height, margin, type, axis, tickMargin] 50 | ); 51 | return ( 52 | 57 | {label} 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/components/Line.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { LineProps } from '../../types'; 3 | 4 | import styled from 'styled-components'; 5 | 6 | const LineComp = styled.path` 7 | fill: none; 8 | stroke-width: 2; 9 | stroke-linejoin: round; 10 | stroke-linecap: round; 11 | `; 12 | 13 | export const Line = React.memo( 14 | ({ fill = 'none', stroke, d }: LineProps): JSX.Element => { 15 | return ( 16 | 23 | ); 24 | } 25 | ); 26 | -------------------------------------------------------------------------------- /src/components/ListeningRect.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import * as d3 from 'd3'; 3 | import { 4 | Margin, 5 | ScaleFunc, 6 | toolTipState, 7 | xAccessorFunc, 8 | yAccessorFunc, 9 | } from '../../types'; 10 | import { getBrowser } from '../utils'; 11 | 12 | export default function ListeningRect({ 13 | data, 14 | layers, 15 | width, 16 | height, 17 | margin, 18 | xScale, 19 | yScale, 20 | xKey, 21 | yKey, 22 | xAccessor, 23 | setTooltip, 24 | }: { 25 | data: any; 26 | layers: d3.Series< 27 | { 28 | [key: string]: number; 29 | }, 30 | string 31 | >[]; 32 | width: number; 33 | height: number; 34 | margin: Margin; 35 | xScale: ScaleFunc; 36 | yScale: ScaleFunc; 37 | xKey: string; 38 | yKey: string; 39 | xAccessor: xAccessorFunc; 40 | yAccessor: yAccessorFunc; 41 | setTooltip?: React.Dispatch; 42 | }) { 43 | const anchor = useRef(null); 44 | 45 | // const [scrollPosition, setScrollPosition] = useState(0); 46 | 47 | // const handleScroll = () => { 48 | // const position = window.pageYOffset; 49 | // setScrollPosition(position); 50 | // }; 51 | 52 | // useEffect(() => { 53 | // window.addEventListener('scroll', handleScroll, { passive: true }); 54 | 55 | // return () => { 56 | // window.removeEventListener('scroll', handleScroll); 57 | // }; 58 | // }, []); 59 | 60 | const tooltipState: toolTipState = { 61 | cursorX: 0, 62 | cursorY: 0, 63 | distanceFromTop: 0, 64 | distanceFromRight: 0, 65 | distanceFromLeft: 0, 66 | data, 67 | }; 68 | 69 | function onMouseMove(e: any) { 70 | const mousePosition = d3.pointer(e); 71 | const hoveredX = xScale.invert(mousePosition[0]); 72 | const hoveredY = yScale.invert(mousePosition[1]); 73 | 74 | // **************************************** 75 | // Find x position 76 | // **************************************** 77 | let closestXValue: any = 0; 78 | const getDistanceFromHoveredX = function (d: any) { 79 | // This StackOverFlow Article helped me with this TS issue 80 | // https://stackoverflow.com/questions/48274028/the-left-hand-and-right-hand-side-of-an-arithmetic-operation-must-be-of-type-a 81 | return Math.abs(xAccessor(d).valueOf() - hoveredX.valueOf()); 82 | }; 83 | 84 | const closestXIndex = d3.leastIndex(data, (a, b) => { 85 | return getDistanceFromHoveredX(a) - getDistanceFromHoveredX(b); 86 | }); 87 | 88 | if (typeof closestXIndex === 'number') { 89 | const closestDataPoint = data[closestXIndex]; 90 | closestXValue = xAccessor(closestDataPoint); 91 | 92 | tooltipState.cursorX = 93 | getBrowser() === 'Chrome' 94 | ? e.nativeEvent.pageX - e.nativeEvent.layerX + xScale(closestXValue) 95 | : e.nativeEvent.pageX - e.nativeEvent.offsetX + xScale(closestXValue); 96 | } 97 | 98 | // **************************************** 99 | // Find y position 100 | // **************************************** 101 | let closestYValue: any = 0; 102 | const closestYSequence = layers.map((layer) => { 103 | if (typeof closestXIndex === 'number') { 104 | return layer[closestXIndex][1]; 105 | } 106 | }); 107 | 108 | const getDistanceFromHoveredY = function (d: any) { 109 | return Math.abs(d - hoveredY.valueOf()); 110 | }; 111 | 112 | const closestYIndex = d3.leastIndex(closestYSequence, (a, b) => { 113 | return getDistanceFromHoveredY(a) - getDistanceFromHoveredY(b); 114 | }); 115 | 116 | if (typeof closestYIndex === 'number') { 117 | if (typeof closestXIndex === 'number') { 118 | closestYValue = layers[closestYIndex][closestXIndex][1]; 119 | 120 | tooltipState.cursorY = 121 | getBrowser() === 'Chrome' 122 | ? e.pageY - e.nativeEvent.layerY + yScale(closestYValue) 123 | : e.pageY - e.nativeEvent.offsetY + yScale(closestYValue); 124 | 125 | const closestYKey: string = layers[closestYIndex].key; 126 | tooltipState.data = { 127 | [xKey]: data[closestXIndex][xKey], 128 | [yKey]: data[closestXIndex][closestYKey], 129 | }; 130 | } 131 | } 132 | 133 | tooltipState.distanceFromTop = yScale(closestYValue) + margin.top; 134 | tooltipState.distanceFromRight = 135 | width - (margin.left + tooltipState.cursorX); 136 | tooltipState.distanceFromLeft = margin.left + tooltipState.cursorX; 137 | 138 | if (setTooltip) { 139 | setTooltip(tooltipState); 140 | } 141 | } 142 | const rectWidth = width - margin.right - margin.left; 143 | const rectHeight = height - margin.bottom - margin.top; 144 | return ( 145 | = 0 ? rectWidth : 0} 148 | height={rectHeight >= 0 ? rectHeight : 0} 149 | fill="transparent" 150 | onMouseMove={onMouseMove} 151 | onMouseLeave={() => (setTooltip ? setTooltip(false) : null)} 152 | /> 153 | ); 154 | } 155 | -------------------------------------------------------------------------------- /src/components/Rectangle.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/restrict-plus-operands */ 2 | import React from 'react'; 3 | import { RectangleProps } from '../../types'; 4 | 5 | import styled from 'styled-components'; 6 | const Bar = styled.rect` 7 | fill-opacity: 0.7; 8 | `; 9 | 10 | const RectangleComp = ({ 11 | data, 12 | dataTestId = 'rectangle', 13 | x, 14 | y, 15 | width, 16 | height, 17 | margin, 18 | cWidth, 19 | fill, 20 | setTooltip, 21 | }: RectangleProps): JSX.Element => { 22 | let tooltipState = { 23 | cursorX: 0, 24 | cursorY: 0, 25 | distanceFromTop: 0, 26 | distanceFromRight: 0, 27 | distanceFromLeft: 0, 28 | data, 29 | }; 30 | 31 | const mouseOver = (e: any) => { 32 | // When the cursor enter the rectangle from the left we need to add half 33 | // of the bar width to the cursor position to calculate the distance from 34 | // right hand side of the page. When the cursor enters the bar from the 35 | // right side of the bar we need to substract half of the bar width. 36 | const offsetFromLeft = e.pageX - e.nativeEvent.offsetX; 37 | const offsetFromTop = e.clientY - e.nativeEvent.offsetY; 38 | const rectMidPoint = (x ?? 0) + width / 2; 39 | const rectTop = y ?? 0; 40 | 41 | if (setTooltip) { 42 | tooltipState = { 43 | cursorX: e.pageX - e.nativeEvent.offsetX + (x ?? 0), 44 | cursorY: e.pageY - e.nativeEvent.offsetY + (y ?? 0), 45 | distanceFromTop: offsetFromTop + margin.top + rectTop, 46 | distanceFromRight: 47 | margin.left + 48 | cWidth + 49 | margin.right - 50 | (offsetFromLeft + margin.left + rectMidPoint), 51 | distanceFromLeft: offsetFromLeft + margin.left + rectMidPoint, 52 | data, 53 | }; 54 | 55 | setTooltip(tooltipState); 56 | } 57 | }; 58 | 59 | const mouseOut = () => { 60 | if (setTooltip) { 61 | setTooltip ? setTooltip(false) : null; 62 | } 63 | }; 64 | 65 | return ( 66 | mouseOver(e)} 74 | onMouseOut={() => mouseOut()} 75 | /> 76 | ); 77 | }; 78 | const compareProps = (prev: RectangleProps, next: RectangleProps) => { 79 | return prev.x === next.x && prev.y === next.y && prev.margin === next.margin; 80 | }; 81 | 82 | export const Rectangle = React.memo(RectangleComp, compareProps); 83 | -------------------------------------------------------------------------------- /src/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/default */ 2 | import React from 'react'; 3 | import { TooltipProps } from '../../types'; 4 | // eslint-disable-next-line import/namespace 5 | // import TooltipContent from './TooltipContent.jsx'; 6 | 7 | const Tooltip = ({ 8 | theme = 'light', 9 | chartType, 10 | data, 11 | cursorX, 12 | cursorY, 13 | distanceFromTop, 14 | distanceFromRight, 15 | distanceFromLeft, 16 | xKey, 17 | yKey, 18 | }: TooltipProps): JSX.Element => { 19 | // ******************** 20 | // TOOLTIP STYLES 21 | // ******************** 22 | 23 | const lightTheme = { 24 | strokeGridLineColor: '#ebebeb', 25 | textColor: '#212121', 26 | axisBaseLineColor: '#ebebeb', 27 | legendBackgroundColor: '#ffffff', 28 | legendBorder: '1px solid #ebebeb', 29 | tooltipTextColor: '#212121', 30 | tooltipBackgroundColor: '#ffffff', 31 | tooltipBorder: '1px solid #ddd', 32 | tooltipShadow: `0 0 10px 0 rgba(80, 80, 80, 0.2)`, 33 | }; 34 | 35 | const darkTheme = { 36 | strokeGridLineColor: '#3d3d3d', 37 | textColor: '#727272', 38 | axisBaseLineColor: '#3d3d3d', 39 | legendBackgroundColor: '#1d1d1d', 40 | legendBorder: '1px solid #3d3d3d', 41 | tooltipTextColor: '#e5e5e5', 42 | tooltipBackgroundColor: '#383838', 43 | tooltipBorder: '1px solid #3d3d3d', 44 | tooltipShadow: `0 0 10px 0 rgba(100, 100, 100, 0.2)`, 45 | }; 46 | 47 | const themes = { 48 | light: lightTheme, 49 | dark: darkTheme, 50 | }; 51 | 52 | const triangleSize = 12; 53 | 54 | // If the tooltip is too close to the top of the screen we will position the 55 | // tooltip below the cursor. 56 | let contentTranslation = ''; 57 | let triangleTranslation = ''; 58 | let triangleBorderTranslation = ''; 59 | // The tooltip is too close to the top of the screen 60 | let moveTooltip: { vertical: string; horizontal: string } = { 61 | vertical: 'none', 62 | horizontal: 'none', 63 | }; 64 | 65 | if (distanceFromTop < 60) { 66 | moveTooltip = { ...moveTooltip, vertical: 'down' }; 67 | } 68 | 69 | if (distanceFromLeft < 70) { 70 | moveTooltip = { ...moveTooltip, horizontal: 'right' }; 71 | } else if (distanceFromRight < 70) { 72 | moveTooltip = { ...moveTooltip, horizontal: 'left' }; 73 | } 74 | 75 | let contentYTranslation = ''; 76 | let triangeYTranslation = ''; 77 | let triangeBorderYTranslation = ''; 78 | switch (moveTooltip.vertical) { 79 | case 'down': 80 | contentYTranslation = `calc(${triangleSize - 4}px)`; 81 | triangeYTranslation = `calc(${triangleSize / 2 + 2}px)`; 82 | triangeBorderYTranslation = `calc(${triangleSize / 2 + 0}px)`; 83 | break; 84 | case 'none': 85 | contentYTranslation = `calc(-102% - ${triangleSize}px)`; 86 | triangeYTranslation = `calc(-102% - ${triangleSize / 2}px)`; 87 | triangeBorderYTranslation = `calc(-100% - ${triangleSize / 2}px)`; 88 | } 89 | 90 | let contentXTranslation = ''; 91 | let triangeXTranslation = ''; 92 | let triangeBorderXTranslation = ''; 93 | switch (moveTooltip.horizontal) { 94 | case 'right': 95 | contentXTranslation = `-20%`; 96 | triangeXTranslation = `-50%`; 97 | triangeBorderXTranslation = `-50%`; 98 | break; 99 | case 'left': 100 | contentXTranslation = `-80%`; 101 | triangeXTranslation = `-50%`; 102 | triangeBorderXTranslation = `-50%`; 103 | break; 104 | case 'none': 105 | contentXTranslation = `-50%`; 106 | triangeXTranslation = `-50%`; 107 | triangeBorderXTranslation = `-50%`; 108 | } 109 | 110 | contentTranslation = `translate(${contentXTranslation}, ${contentYTranslation})`; 111 | triangleTranslation = `translate(${triangeXTranslation}, ${triangeYTranslation}) rotate(45deg)`; 112 | triangleBorderTranslation = `translate(${triangeBorderXTranslation}, ${triangeBorderYTranslation}) rotate(45deg)`; 113 | 114 | const tooltipWrapperStyle: React.CSSProperties | undefined = { 115 | left: cursorX, 116 | top: cursorY, 117 | transform: 'translate(-50%, -50%)', 118 | position: 'absolute', 119 | pointerEvents: 'none', 120 | fontFamily: 'Tahoma, Geneva, Verdana, sans-serif', 121 | }; 122 | 123 | const contentStyle: React.CSSProperties | undefined = { 124 | position: 'absolute', 125 | margin: '4px 4px', 126 | border: `6px solid ${themes[theme].tooltipBackgroundColor}`, 127 | borderRight: `12px solid ${themes[theme].tooltipBackgroundColor}`, 128 | borderBottom: `6px solid ${themes[theme].tooltipBackgroundColor}`, 129 | borderLeft: `12px solid ${themes[theme].tooltipBackgroundColor}`, 130 | padding: '0.6em 1em', 131 | borderRadius: '4px', 132 | minWidth: '140px', 133 | maxWidth: '240px', 134 | transform: contentTranslation, 135 | backgroundColor: `${themes[theme].tooltipBackgroundColor}`, 136 | textAlign: 'center', 137 | lineHeight: '1.4em', 138 | fontSize: '12px', 139 | color: `${themes[theme].tooltipTextColor}`, 140 | // border: `${themes[theme].tooltipBorder}`, 141 | zIndex: '9', 142 | transition: 'all 0.1s ease-out', 143 | boxShadow: `${themes[theme].tooltipShadow}`, 144 | pointerEvents: 'none', 145 | // wordBreak: 'break-all', 146 | }; 147 | 148 | const triangleStyle: React.CSSProperties | undefined = { 149 | content: '', 150 | position: 'absolute', 151 | width: `${triangleSize}px`, 152 | height: `${triangleSize}px`, 153 | backgroundColor: `${themes[theme].tooltipBackgroundColor}`, 154 | transform: triangleTranslation, 155 | transformOrigin: 'center center', 156 | zIndex: '10', 157 | transition: 'all 0.1s ease-out', 158 | pointerEvents: 'none', 159 | }; 160 | 161 | const triangleBorderStyle: React.CSSProperties | undefined = { 162 | content: '', 163 | position: 'absolute', 164 | width: `${triangleSize}px`, 165 | height: `${triangleSize}px`, 166 | background: `${themes[theme].tooltipBackgroundColor}`, 167 | transform: triangleBorderTranslation, 168 | transformOrigin: 'center center', 169 | boxShadow: `${themes[theme].tooltipShadow}`, 170 | zIndex: '8', 171 | transition: 'all 0.1s ease-out', 172 | pointerEvents: 'none', 173 | }; 174 | 175 | let xValString = data[xKey as string]; 176 | if (data[xKey as string] instanceof Date) { 177 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 178 | xValString = `${xValString.getFullYear()}-${xValString.getMonth()}-${xValString.getDay()}`; 179 | } 180 | 181 | let yValString = data[yKey as string]; 182 | if (!isNaN(data[yKey as string])) { 183 | yValString = `${Math.round(yValString * 100) / 100}`; 184 | } 185 | 186 | return ( 187 |
192 |
193 |
194 | {xKey} {xValString} 195 |
196 |
197 | {yKey} {yValString} 198 |
199 |
200 |
201 |
202 |
203 | ); 204 | }; 205 | 206 | export default Tooltip; 207 | -------------------------------------------------------------------------------- /src/components/TooltipContent.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/restrict-plus-operands */ 2 | // import { clearConfigCache } from 'prettier'; 3 | // import React from 'react'; 4 | // import styled from 'styled-components'; 5 | 6 | // const triangleSize = 12; 7 | // const shadowElevationHigh = `0 0 10px 0 rgba(80, 80, 80, 0.2)`; 8 | 9 | // interface StyledTooltipProps { 10 | // cursorX: number; 11 | // cursorY: number; 12 | // contentTranslation: string; 13 | // triangleTranslation: string; 14 | // triangleBorderTranslation: string; 15 | // xKey: string; 16 | // xValString: string; 17 | // yKey: string; 18 | // yValString: string; 19 | // children?: JSX.Element[]; 20 | // } 21 | 22 | // const StyledTooltip = styled.div` 23 | // left: ${(props) => props.cursorX}; 24 | // top: ${(props) => props.cursorY}; 25 | // transform: translate(-50%, -50%); 26 | // position: absolute; 27 | // pointerEvents: none; 28 | // fontFamily: Tahoma, Geneva, Verdana, sans-serif, 29 | // fontSize: 12px, 30 | // color: ${(props) => props.theme.tooltipBackgroundColor}, 31 | // `; 32 | 33 | // const StyledContent = styled.div` 34 | // position: absolute; 35 | // margin: 4px 4px; 36 | // padding: 0.6em 1em; 37 | // borde-radius: 4px; 38 | // min-width: 140px; 39 | // max-width: 240px; 40 | // transform: ${(props) => props.theme.contentTranslation}; 41 | // background: ${(props) => props.theme.tooltipBackgroundColor}; 42 | // textalign: center; 43 | // lineheight: 1.4em; 44 | // fontsize: 1em; 45 | // `; 46 | 47 | // const StyledTriangleBorder = styled.div` 48 | // content: '', 49 | // position: absolute; 50 | // width: triangleSize; 51 | // height: triangleSize; 52 | // background: ${(props) => props.theme.tooltipBorder}; 53 | // transform: ${(props) => props.theme.triangleBorderTranslation};, 54 | // transform-origin: center center; 55 | // boxShadow: shadowElevationHigh, 56 | // z-index: 8; 57 | // transition: all 0.1s ease-out; 58 | // pointer-events: none; 59 | // `; 60 | 61 | // const StyledTriangle = styled.div` 62 | // content: ''; 63 | // position: absolute; 64 | // width: triangleSize; 65 | // height: triangleSize; 66 | // background: backgroundColor, 67 | // transform: ${(props) => props.theme.triangleTranslation}; 68 | // transform-origin: center center; 69 | // z-index: 10; 70 | // transition: all 0.1s ease-out; 71 | // pointer-events: none; 72 | // `; 73 | 74 | // const TooltipContent = ({ 75 | // cursorX = 0, 76 | // cursorY = 0, 77 | // contentTranslation = '', 78 | // triangleTranslation = '', 79 | // triangleBorderTranslation = '', 80 | // xKey = '', 81 | // xValString = '', 82 | // yKey = '', 83 | // yValString = '', 84 | // }) => { 85 | // // const styles = SIZES[size]; 86 | 87 | // return ( 88 | // 89 | // 90 | //
91 | // {xKey} {xValString} 92 | //
93 | //
94 | // {yKey} {yValString} 95 | //
96 | //
97 | // 98 | // 99 | //
100 | // ); 101 | // }; 102 | 103 | // export default TooltipContent; 104 | -------------------------------------------------------------------------------- /src/components/VoronoiCell.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/restrict-plus-operands */ 2 | import React from 'react'; 3 | import { VoronoiProps } from '../../types'; 4 | 5 | export const VoronoiCell = ({ 6 | fill, 7 | stroke, 8 | opacity, 9 | d, 10 | cellCenter, 11 | setTooltip, 12 | data, 13 | margin, 14 | cWidth, 15 | }: VoronoiProps): JSX.Element => { 16 | // The code below was commented out because of the performance issues we ran 17 | // into when charts are taking in large data sets 18 | // TODO: Figure out how to performantly use scroll to improve the performance. 19 | // const [scrollPosition, setScrollPosition] = useState(0); 20 | 21 | // const handleScroll = () => { 22 | // const position = window.pageYOffset; 23 | // setScrollPosition(position); 24 | // }; 25 | 26 | // useEffect(() => { 27 | // window.addEventListener('scroll', handleScroll, { passive: true }); 28 | 29 | // return () => { 30 | // window.removeEventListener('scroll', handleScroll); 31 | // }; 32 | // }, []); 33 | 34 | const tooltipState = { 35 | cursorX: 0, 36 | cursorY: 0, 37 | distanceFromTop: 0, 38 | distanceFromRight: 0, 39 | distanceFromLeft: 0, 40 | data, 41 | }; 42 | 43 | const onMouseMove = (e: any) => { 44 | const tooltipState = { 45 | cursorX: e.nativeEvent.pageX - e.nativeEvent.offsetX + cellCenter.cx, 46 | cursorY: e.nativeEvent.pageY - e.nativeEvent.offsetY + cellCenter.cy, 47 | distanceFromTop: 0, 48 | distanceFromRight: 0, 49 | distanceFromLeft: 0, 50 | data, 51 | }; 52 | 53 | tooltipState.distanceFromTop = cellCenter.cy + margin.top; 54 | tooltipState.distanceFromRight = 55 | margin.left + 56 | cWidth + 57 | margin.right - 58 | (margin.left + tooltipState.cursorX); 59 | tooltipState.distanceFromLeft = margin.left + tooltipState.cursorX; 60 | 61 | setTooltip ? setTooltip(tooltipState) : null; 62 | }; 63 | 64 | const onMouseOut = (e: any) => { 65 | if (tooltipState) { 66 | tooltipState.cursorY = 67 | tooltipState.cursorY - e.nativeEvent.pageY + e.nativeEvent.offsetY; 68 | tooltipState.cursorX = 69 | tooltipState.cursorX - e.nativeEvent.pageX + e.nativeEvent.offsetX; 70 | } 71 | setTooltip ? setTooltip(false) : null; 72 | }; 73 | 74 | return ( 75 | onMouseMove(e)} 82 | onMouseOut={(e) => onMouseOut(e)} 83 | > 84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /src/components/VoronoiWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Data, VoronoiBody } from '../../types'; 3 | import { VoronoiCell } from './VoronoiCell'; 4 | 5 | export const VoronoiWrapper = React.memo( 6 | ({ 7 | data, 8 | voronoi, 9 | xScale, 10 | yScale, 11 | xAccessor, 12 | yAccessor, 13 | setTooltip, 14 | margin, 15 | cWidth, 16 | }: VoronoiBody): JSX.Element => { 17 | return ( 18 | 19 | {data.map((element: Data, i: number) => ( 20 | 36 | ))} 37 | 38 | ); 39 | } 40 | ); 41 | -------------------------------------------------------------------------------- /src/functionality/grid.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as d3 from 'd3'; 3 | import { Margin } from '../../types'; 4 | 5 | import styled from 'styled-components'; 6 | 7 | const GridLine = styled.line` 8 | stroke: ${(props) => props.theme.strokeGridLineColor}; 9 | `; 10 | 11 | const AxisBaseLine = styled.line` 12 | stroke: ${(props) => props.theme.axisBaseLineColor}; 13 | `; 14 | 15 | export function gridGenerator( 16 | mode: 'light' | 'dark', 17 | type: 'top' | 'bottom' | 'left' | 'right', 18 | xGrid: boolean | undefined, 19 | yGrid: boolean | undefined, 20 | xTicksValue: (number | Date | undefined)[], 21 | scale: 22 | | d3.ScaleLinear 23 | | d3.ScaleTime, 24 | height: number, 25 | width: number, 26 | margin: Margin 27 | ): JSX.Element[] { 28 | let grid: JSX.Element[] = []; 29 | switch (true) { 30 | case type === 'bottom' && xGrid: 31 | grid = (xTicksValue ? xTicksValue : scale.ticks()).map( 32 | (tick: any, i: number) => { 33 | const y2 = -height + margin.bottom + margin.top; 34 | return ( 35 | 44 | ); 45 | } 46 | ); 47 | break; 48 | case type === 'top' && xGrid: 49 | grid = (xTicksValue ? xTicksValue : scale.ticks()).map( 50 | (tick: any, i: number) => { 51 | const y2 = height - margin.bottom - margin.top; 52 | return ( 53 | 40 ? y2 : 40} 60 | stroke="#bdc3c7" 61 | /> 62 | ); 63 | } 64 | ); 65 | break; 66 | case type === 'left' && yGrid: 67 | grid = (xTicksValue ? xTicksValue : scale.ticks()).map( 68 | (tick: any, i: number) => { 69 | const x2 = width - margin.right - margin.left; 70 | return ( 71 | 40 ? x2 : 40} 76 | y1={scale(tick)} 77 | y2={scale(tick)} 78 | /> 79 | ); 80 | } 81 | ); 82 | break; 83 | case type === 'right' && yGrid: 84 | grid = (xTicksValue ? xTicksValue : scale.ticks()).map( 85 | (tick: any, i: number) => { 86 | const x2 = -width + margin.right + margin.left; 87 | return ( 88 | 96 | ); 97 | } 98 | ); 99 | 100 | break; 101 | } 102 | return grid; 103 | } 104 | -------------------------------------------------------------------------------- /src/functionality/voronoi.tsx: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3'; 2 | import { 3 | Margin, 4 | Data, 5 | ScaleFunc, 6 | xAccessorFunc, 7 | yAccessorFunc, 8 | } from '../../types'; 9 | 10 | export function d3Voronoi( 11 | data: Data[], 12 | xScale: ScaleFunc, 13 | yScale: d3.ScaleLinear, 14 | xAccessor: xAccessorFunc, 15 | yAccessor: yAccessorFunc, 16 | height: number, 17 | width: number, 18 | margin: Margin 19 | ) { 20 | const delaunay = d3.Delaunay.from( 21 | data, 22 | (d) => xScale(xAccessor(d)), 23 | (d) => yScale(yAccessor(d)) 24 | ); 25 | let voronoi: d3.Voronoi = null as unknown as d3.Voronoi; 26 | const xMax = width - margin.right - margin.left; 27 | const yMax = height - margin.bottom - margin.top; 28 | if (height && width) { 29 | voronoi = delaunay.voronoi([ 30 | 0, 31 | 0, 32 | xMax > 40 ? xMax : 40, 33 | yMax > 40 ? yMax : 40, 34 | ]); 35 | } 36 | return voronoi; 37 | } 38 | -------------------------------------------------------------------------------- /src/functionality/xScale.tsx: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3'; 2 | import { Margin, Data, ScaleFunc, xAccessorFunc } from '../../types'; 3 | 4 | export function xScaleDef( 5 | data: Data[], 6 | xDataType: 'date' | 'number', 7 | xAccessor: xAccessorFunc, 8 | margin: Margin, 9 | width: number, 10 | chart: 11 | | 'scatter-plot' 12 | | 'line-chart' 13 | | 'area-chart' 14 | | 'bar-chart' 15 | | 'pie-chart' 16 | ) { 17 | let xScale: ScaleFunc; 18 | const [xMin, xMax] = d3.extent(data, xAccessor); 19 | const rangeMax = width - margin.right - margin.left; 20 | switch (xDataType) { 21 | case 'number': 22 | xScale = d3 23 | .scaleLinear() 24 | .domain([xMin ?? 0, xMax ?? 0]) 25 | .range([0, rangeMax > 40 ? rangeMax : 40]); 26 | chart === 'scatter-plot' ? xScale.nice() : null; 27 | break; 28 | case 'date': 29 | xScale = d3 30 | .scaleTime() 31 | .domain([xMin ?? 0, xMax ?? 0]) 32 | .range([0, rangeMax > 40 ? rangeMax : 40]); 33 | chart === 'scatter-plot' ? xScale.nice() : null; 34 | break; 35 | } 36 | 37 | return { xScale, xMin, xMax }; 38 | } 39 | -------------------------------------------------------------------------------- /src/functionality/yScale.tsx: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3'; 2 | import { Margin, Data, yAccessorFunc } from '../../types'; 3 | 4 | export function yScaleDef( 5 | data: Data[], 6 | yAccessor: yAccessorFunc, 7 | margin: Margin, 8 | height: number, 9 | chartType?: string, 10 | groupBy?: string 11 | ) { 12 | let yMin: number; 13 | let yMax: number; 14 | 15 | if (groupBy && (chartType === 'area-chart' || chartType === 'bar-chart')) { 16 | yMax = d3.max(data, (layer: any) => { 17 | // scan each layer's data points for the highest value 18 | return d3.max(layer, (sequence: [number, number, any]) => sequence[1]); 19 | }) as number; 20 | yMin = d3.min(data, (layer: any) => { 21 | // scan each layer's data points for the lowest value 22 | return d3.min(layer, (sequence: [number, number, any]) => sequence[0]); 23 | }) as number; 24 | } else if ( 25 | !groupBy && 26 | (chartType === 'area-chart' || chartType === 'bar-chart') 27 | ) { 28 | yMax = d3.max(data, yAccessor) as number; 29 | yMin = Math.min(0, d3.min(data, yAccessor) as number); 30 | } else { 31 | yMax = d3.max(data, yAccessor) as number; 32 | yMin = d3.min(data, yAccessor) as number; 33 | } 34 | const rangeMax = height - margin.top - margin.bottom; 35 | const yScale = d3 36 | .scaleLinear() 37 | .domain([yMin, yMax]) 38 | .range([rangeMax > 40 ? rangeMax : 40, 0]) 39 | .nice(); 40 | return yScale; 41 | } 42 | -------------------------------------------------------------------------------- /src/hooks/useD3.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | import React from 'react'; 3 | import * as d3 from 'd3'; 4 | 5 | export const useD3 = ( 6 | renderChartFn: ( 7 | arg: d3.Selection 8 | ) => void, 9 | dependencies: any[] 10 | ) => { 11 | const ref = React.useRef(null as unknown as SVGSVGElement); 12 | React.useEffect(() => { 13 | renderChartFn(d3.select(ref.current)); 14 | return () => {}; 15 | }, dependencies); 16 | return ref; 17 | }; 18 | -------------------------------------------------------------------------------- /src/hooks/useEnvEffect.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useLayoutEffect } from 'react'; 2 | const hook = typeof window === 'undefined' ? useEffect : useLayoutEffect; 3 | export default hook; 4 | -------------------------------------------------------------------------------- /src/hooks/useMousePosition.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export const useMousePosition = () => { 4 | const [position, setPosition] = useState({ x: 0, y: 0 }); 5 | 6 | useEffect(() => { 7 | const setFromEvent = (e: any) => 8 | setPosition({ x: e.clientX, y: e.clientY }); 9 | window.addEventListener('mousemove', setFromEvent); 10 | 11 | return () => { 12 | window.removeEventListener('mousemove', setFromEvent); 13 | }; 14 | }, []); 15 | 16 | return position; 17 | }; 18 | -------------------------------------------------------------------------------- /src/hooks/useResponsive.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import useEnvEffect from './useEnvEffect'; 3 | export const useResponsive = () => { 4 | const [windowSize, setWindowSize] = useState<[number, number]>([0, 0]); 5 | const [cHeight, setCHeight] = useState(0); 6 | const [cWidth, setCWidth] = useState(0); 7 | 8 | function updateSize() { 9 | setWindowSize([window.innerWidth, window.innerHeight]); 10 | } 11 | 12 | const anchor = useRef(null as any); 13 | 14 | useEnvEffect(() => { 15 | window.addEventListener('resize', updateSize); 16 | updateSize(); 17 | return () => window.removeEventListener('resize', updateSize); 18 | }, []); 19 | 20 | useEffect(() => { 21 | const container = anchor.current.getBoundingClientRect(); 22 | setCHeight(container.height > 100 ? container.height : 100); 23 | setCWidth(container.width > 100 ? container.width : 100); 24 | }, [windowSize]); 25 | 26 | return { anchor, cHeight, cWidth }; 27 | }; 28 | -------------------------------------------------------------------------------- /src/hooks/useWindowDimensions.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export default function useWindowDimensions() { 4 | const [windowDimensions, setWindowDimensions] = useState({ 5 | width: 0, 6 | height: 0, 7 | }); 8 | 9 | useEffect(() => { 10 | function getWindowDimensions() { 11 | const { innerWidth: width, innerHeight: height } = window; 12 | return { 13 | width, 14 | height, 15 | }; 16 | } 17 | function handleResize() { 18 | setWindowDimensions(getWindowDimensions()); 19 | } 20 | handleResize(); 21 | 22 | window.addEventListener('resize', handleResize); 23 | return () => window.removeEventListener('resize', handleResize); 24 | }, []); 25 | 26 | return windowDimensions; 27 | } 28 | -------------------------------------------------------------------------------- /src/index.dev.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | ReactDOM.render( 6 | <> 7 | 8 | , 9 | document.getElementById('root') 10 | ); 11 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import LineChart from './charts/LineChart/LineChart'; 3 | import ScatterPlot from './charts/ScatterPlot/ScatterPlot'; 4 | import BarChart from './charts/BarChart/BarChart'; 5 | import AreaChart from './charts/AreaChart/AreaChart'; 6 | import PieChart from './charts/PieChart/PieChart'; 7 | export { AreaChart, BarChart, PieChart, ScatterPlot, LineChart }; 8 | -------------------------------------------------------------------------------- /src/styles/componentStyles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | height: 100vh; 5 | width: 100vw; 6 | background-color: #ffffff; 7 | /* background-color: #1d1d1d; */ 8 | `; 9 | -------------------------------------------------------------------------------- /src/styles/globals.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | 3 | const GlobalStyle = createGlobalStyle` 4 | *{ 5 | margin: 0; 6 | padding: 0; 7 | outline:0; 8 | box-sizing:border-box; 9 | font-family: Tahoma, Geneva, Verdana, sans-serif; 10 | /* background-color: #1d1d1d; */ 11 | /* background-color: #fff; */ 12 | } 13 | `; 14 | 15 | export default GlobalStyle; 16 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Margin, LegendPos } from '../types'; 2 | import { DiscreteAxis } from './components/DiscreteAxis'; 3 | 4 | const LightTheme = { 5 | strokeGridLineColor: '#ebebeb', 6 | textColor: '#8c8c8c', 7 | axisBaseLineColor: '#ebebeb', 8 | legendTextColor: '#212121', 9 | legendBackgroundColor: '#ffffff', 10 | legendBorder: '1px solid #ebebeb', 11 | }; 12 | 13 | const DarkTheme = { 14 | strokeGridLineColor: '#3d3d3d', 15 | textColor: '#727272', 16 | axisBaseLineColor: '#3d3d3d', 17 | legendTextColor: '#e5e5e5', 18 | legendBackgroundColor: '#1d1d1d', 19 | legendBorder: '1px solid #3d3d3d', 20 | }; 21 | 22 | export const themes = { 23 | light: LightTheme, 24 | dark: DarkTheme, 25 | }; 26 | 27 | export function getBrowser() { 28 | let browser; 29 | const sUsrAg = navigator.userAgent; 30 | if (sUsrAg.indexOf('Firefox') > -1) { 31 | browser = 'Mozilla Firefox'; 32 | // "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0" 33 | } else if (sUsrAg.indexOf('SamsungBrowser') > -1) { 34 | browser = 'Samsung Internet'; 35 | // "Mozilla/5.0 (Linux; Android 9; SAMSUNG SM-G955F Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/9.4 Chrome/67.0.3396.87 Mobile Safari/537.36 36 | } else if (sUsrAg.indexOf('Opera') > -1 || sUsrAg.indexOf('OPR') > -1) { 37 | browser = 'Opera'; 38 | // "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 OPR/57.0.3098.106" 39 | } else if (sUsrAg.indexOf('Trident') > -1) { 40 | browser = 'Microsoft Internet Explorer'; 41 | // "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; Zoom 3.6.0; wbx 1.0.0; rv:11.0) like Gecko" 42 | } else if (sUsrAg.indexOf('Edge') > -1) { 43 | browser = 'Microsoft Edge'; 44 | // "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299" 45 | } else if (sUsrAg.indexOf('Chrome') > -1) { 46 | browser = 'Chrome'; 47 | // "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/66.0.3359.181 Chrome/66.0.3359.181 Safari/537.36" 48 | } else if (sUsrAg.indexOf('Safari') > -1) { 49 | browser = 'Safari'; 50 | // "Mozilla/5.0 (iPhone; CPU iPhone OS 11_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.0 Mobile/15E148 Safari/604.1 980x1306" 51 | } else { 52 | browser = 'unknown'; 53 | } 54 | return browser; 55 | } 56 | 57 | export const EXTRA_LEGEND_MARGIN = 6; 58 | 59 | export function getXAxisCoordinates( 60 | xAxis: 'top' | 'bottom' | false = 'bottom', 61 | height: number, 62 | margin: Margin 63 | ) { 64 | const xAxisX = 0; 65 | const marginDifference = height - margin.top - margin.bottom; 66 | let xAxisY: number = marginDifference > 40 ? marginDifference : 40; 67 | 68 | if (xAxis === 'top') xAxisY = 0; 69 | 70 | return { 71 | xAxisX, 72 | xAxisY, 73 | }; 74 | } 75 | 76 | export function getYAxisCoordinates( 77 | yAxis: 'left' | 'right' | false = 'left', 78 | width: number, 79 | margin: Margin 80 | ) { 81 | let yAxisX = 0; 82 | const yAxisY = 0; 83 | const marginDifference = width - margin.left - margin.right; 84 | if (yAxis === 'right') yAxisX = marginDifference > 40 ? marginDifference : 40; 85 | 86 | return { 87 | yAxisX, 88 | yAxisY, 89 | }; 90 | } 91 | 92 | export function getMargins( 93 | xAxis: 'top' | 'bottom' | false = 'bottom', 94 | yAxis: 'left' | 'right' | false = 'left', 95 | xAxisLabel: string | undefined, 96 | yAxisLabel: string | undefined 97 | ) { 98 | let left = 20, 99 | right = 20, 100 | top = 20, 101 | bottom = 20; 102 | 103 | function addVerticalMargin() { 104 | switch (xAxis) { 105 | case 'top': 106 | top += 40; 107 | break; 108 | case 'bottom': 109 | bottom += 40; 110 | } 111 | } 112 | 113 | function addHorizontalMargin() { 114 | switch (yAxis) { 115 | case 'left': 116 | left += 40; 117 | break; 118 | case 'right': 119 | right += 40; 120 | } 121 | } 122 | 123 | if (xAxis) addVerticalMargin(); 124 | if (xAxis && xAxisLabel) addVerticalMargin(); 125 | if (yAxis) addHorizontalMargin(); 126 | if (yAxis && yAxisLabel) addHorizontalMargin(); 127 | 128 | return { left, right, top, bottom }; 129 | } 130 | 131 | export function getMarginsWithLegend( 132 | xAxis: 'top' | 'bottom' | false | undefined, 133 | yAxis: 'left' | 'right' | false | undefined, 134 | xAxisLabel: string | undefined, 135 | yAxisLabel: string | undefined, 136 | legend: LegendPos = false, 137 | xOffset = 0, 138 | yOffset = 0, 139 | // legendOffset: [number, number] = [0, 0], // ideally this should be mandatory if legend is truthy 140 | cWidth = 0, // ideally this should be mandatory if legend is truthy 141 | cHeight = 0, // ideally this should be mandatory if legend is truthy 142 | tickMargin?: number 143 | ) { 144 | let left = 20, 145 | right = 20, 146 | top = 20, 147 | bottom = tickMargin ? tickMargin + 20 : 20; 148 | function addVerticalMargin() { 149 | switch (xAxis) { 150 | case 'top': 151 | top += 40; 152 | break; 153 | case 'bottom': 154 | bottom += 40; 155 | } 156 | } 157 | function addHorizontalMargin() { 158 | switch (yAxis) { 159 | case 'left': 160 | left += 40; 161 | break; 162 | case 'right': 163 | right += 40; 164 | } 165 | } 166 | 167 | function addVerticalMargin1() { 168 | switch (xAxis) { 169 | case 'top': 170 | top += 20; 171 | break; 172 | case 'bottom': 173 | bottom += 20; 174 | break; 175 | case undefined: 176 | bottom += 20; 177 | break; 178 | case false: 179 | bottom += 20; 180 | break; 181 | } 182 | } 183 | function addHorizontalMargin1() { 184 | switch (yAxis) { 185 | case 'left': 186 | left += 20; 187 | break; 188 | case 'right': 189 | right += 20; 190 | break; 191 | case undefined: 192 | left += 20; 193 | break; 194 | case false: 195 | left += 20; 196 | break; 197 | } 198 | } 199 | if (xAxis) addVerticalMargin(); 200 | if (xAxisLabel) addVerticalMargin1(); 201 | if (yAxis) addHorizontalMargin(); 202 | if (yAxisLabel) addHorizontalMargin1(); 203 | 204 | if (legend === true) legend = 'right'; 205 | if (legend) { 206 | // make room for legend by adjusting margin: 207 | // const xOffset = legendOffset[0]; 208 | // const yOffset = legendOffset[1]; 209 | let marginExt = 0; 210 | switch (legend) { 211 | case 'top': 212 | case 'left-top': 213 | case 'right-top': 214 | top += yOffset + EXTRA_LEGEND_MARGIN; 215 | break; 216 | case 'left-bottom': 217 | case 'right-bottom': 218 | case 'bottom': 219 | bottom += yOffset + EXTRA_LEGEND_MARGIN; 220 | break; 221 | case 'left': 222 | case 'top-left': 223 | case 'bottom-left': 224 | marginExt = left + xOffset + EXTRA_LEGEND_MARGIN; 225 | if (marginExt > 0) left = marginExt; 226 | break; 227 | case 'top-right': 228 | case 'bottom-right': 229 | case 'right': 230 | default: 231 | marginExt = right + xOffset + EXTRA_LEGEND_MARGIN; 232 | if (marginExt > 0) right = marginExt; 233 | } 234 | } 235 | return { left, right, top, bottom }; 236 | } 237 | 238 | export const LABEL_MARGIN = 20; 239 | export const AXIS_MARGIN = 40; 240 | 241 | export function getAxisLabelCoordinates( 242 | x: number, 243 | y: number, 244 | height: number, 245 | width: number, 246 | margin: Margin, 247 | type: string | boolean, 248 | axis: boolean, 249 | tickMargin = 0 250 | ) { 251 | let rotate = 0; 252 | let axisLabelX = 0; 253 | let axisLabelY = 0; 254 | const labelMargin = LABEL_MARGIN; 255 | const axisMargin = AXIS_MARGIN + tickMargin; 256 | let position: number; 257 | switch (type) { 258 | case 'top': 259 | position = width - margin.right - margin.left; 260 | axisLabelX = position > 40 ? position / 2 : 40 / 2; 261 | axisLabelY = y - labelMargin / 2 - axisMargin; 262 | rotate = 0; 263 | break; 264 | case 'right': 265 | axisLabelX = x + labelMargin / 2 + axisMargin; 266 | position = height - margin.top - margin.bottom; 267 | axisLabelY = position > 40 ? position / 2 : 40 / 2; 268 | rotate = 90; 269 | break; 270 | case 'bottom': 271 | position = width - margin.right - margin.left; 272 | axisLabelX = position > 40 ? position / 2 : 40 / 2; 273 | axisLabelY = axis ? y + labelMargin + axisMargin : y + labelMargin; 274 | rotate = 0; 275 | break; 276 | case 'left': 277 | axisLabelX = axis ? -labelMargin / 2 - axisMargin : -labelMargin; 278 | position = height - margin.top - margin.bottom; 279 | axisLabelY = position > 40 ? position / 2 : 40 / 2; 280 | rotate = -90; 281 | break; 282 | case false: 283 | axisLabelX = -labelMargin / 2; 284 | axisLabelY = (height - margin.top - margin.bottom) / 2; 285 | rotate = -90; 286 | } 287 | return { 288 | axisLabelX, 289 | axisLabelY, 290 | rotate, 291 | }; 292 | } 293 | 294 | export function checkRadiusDimension( 295 | height: number, 296 | width: number, 297 | radius: number | string, 298 | margin: Margin, 299 | legend?: LegendPos 300 | ) { 301 | //TODO: add minimum radius here? 302 | 303 | let legendMargin = 0; 304 | const screenSize = Math.min(height, width); 305 | switch (legend) { 306 | case 'top': 307 | case 'left-top': 308 | case 'right-top': 309 | legendMargin = margin.top; 310 | break; 311 | case 'bottom': 312 | case 'left-bottom': 313 | case 'right-bottom': 314 | legendMargin = Math.abs(margin.bottom); 315 | break; 316 | case 'left': 317 | case 'top-left': 318 | case 'bottom-left': 319 | legendMargin = margin.left; 320 | break; 321 | case 'right': 322 | case 'top-right': 323 | case 'bottom-right': 324 | legendMargin = margin.right; 325 | break; 326 | } 327 | 328 | if (typeof radius === 'string' && radius.endsWith('%')) { 329 | radius = radius.slice(0, -1); 330 | return ((Number(radius) * (screenSize - legendMargin)) / 2) * 0.01; 331 | } else if (Number(radius) > (screenSize - legendMargin) / 2) { 332 | return (screenSize - legendMargin) / 2; 333 | } else { 334 | return Number(radius); 335 | } 336 | } 337 | 338 | export function calculateOuterRadius( 339 | height: number, 340 | width: number, 341 | margin: Margin 342 | ) { 343 | const radius = Math.min( 344 | (height - margin.top - margin.bottom) / 2, 345 | (width - margin.left - margin.right) / 2 346 | ); 347 | return Math.max(radius, 20); 348 | } 349 | 350 | interface CountryDataProps { 351 | key: string; 352 | values: Array>; 353 | } 354 | 355 | export function findYDomainMax(data: any, keyArr: string[]) { 356 | let yDomainMax = 0; 357 | data.forEach((obj: any) => { 358 | let stackedHeight = 0; 359 | for (const key of keyArr) { 360 | stackedHeight += obj[key]; 361 | if (stackedHeight > yDomainMax) yDomainMax = stackedHeight; 362 | } 363 | }); 364 | return yDomainMax; 365 | } 366 | 367 | interface CountryDataProps { 368 | key: string; 369 | values: Array>; 370 | } 371 | 372 | export function transformCountryData(arr: CountryDataProps[]) { 373 | const transformed = []; 374 | for (let i = 0; i < arr[0].values.length; i++) { 375 | const entry: any = { 376 | // TODO: get rid of any? 377 | date: arr[0].values[i][0], 378 | }; 379 | for (let j = 0; j < arr.length; j++) { 380 | entry[arr[j].key] = arr[j].values[i][1]; 381 | } 382 | transformed.push(entry); 383 | } 384 | return transformed; 385 | } 386 | 387 | export function transformSkinnyToWide( 388 | arr: any, 389 | keys: any, 390 | groupBy: string | undefined, 391 | xDataKey: string | undefined, 392 | yDataKey: string | undefined 393 | ) { 394 | const outputArr = []; 395 | // Find unique x vals. create 1 object with date prop for each date 396 | const rowsArr: any = []; 397 | for (const entry of arr) { 398 | if (!rowsArr.includes(entry[xDataKey ?? ''])) 399 | rowsArr.push(entry[xDataKey ?? '']); 400 | } 401 | // create 1 prop with key for each val in keys, and associated val of 'value' from input arr at the object with current date & key name 402 | for (const rowValue of rowsArr) { 403 | const rowObj: any = {}; 404 | rowObj[xDataKey ?? ''] = rowValue; 405 | 406 | for (const key of keys) { 407 | rowObj[key] = arr.reduce((val: number | undefined, currentRow: any) => { 408 | if ( 409 | currentRow[xDataKey ?? ''] === rowValue && 410 | currentRow[groupBy ?? ''] === key 411 | ) { 412 | return currentRow[yDataKey ?? '']; 413 | } else { 414 | return val; 415 | } 416 | }, null); 417 | } 418 | outputArr.push(rowObj); 419 | } 420 | return outputArr; 421 | } 422 | 423 | export function inferXDataType(el: any, xKey: string) { 424 | let xDataType: 'number' | 'date' | undefined; 425 | if ( 426 | (typeof el[xKey] === 'string' && !isNaN(Date.parse(el[xKey]))) || 427 | el[xKey] instanceof Date 428 | ) { 429 | xDataType = 'date'; 430 | } else if (typeof el[xKey] === 'number') { 431 | xDataType = 'number'; 432 | } else { 433 | throw new Error('Incorrect datatype'); 434 | } 435 | return xDataType; 436 | } 437 | -------------------------------------------------------------------------------- /tests/Test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import BarChart from '../src/charts/BarChart/BarChart'; 3 | import LineChart from '../src/charts/LineChart/LineChart'; 4 | import AreaChart from '../src/charts/AreaChart/AreaChart'; 5 | import ScatterPlot from '../src/charts/ScatterPlot/ScatterPlot'; 6 | import PieChart from '../src/charts/PieChart/PieChart'; 7 | 8 | import unemployment from '../data/unemployment.json'; 9 | import penguins from '../data/penguins.json'; 10 | import portfolio from '../data/portfolio.json'; 11 | import fruit from '../data/fruit.json'; 12 | 13 | // eslint-disable-next-line react/display-name 14 | const Test = React.memo((): JSX.Element => { 15 | return ( 16 |
17 | 26 | 40 | 52 | 67 | 81 |
82 | ); 83 | }); 84 | 85 | export default Test; 86 | -------------------------------------------------------------------------------- /tests/components/Circle.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom'; 3 | import { render, screen } from '@testing-library/react'; 4 | import { Circle } from '../../src/components/Circle'; 5 | import { CircleProps } from '../../types'; 6 | 7 | const circleProps: CircleProps = { 8 | cx: 10, 9 | cy: 10, 10 | r: '1', 11 | color: 'red', 12 | }; 13 | 14 | const setup = (props: CircleProps) => { 15 | return render( 16 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | describe('Circle test', () => { 23 | test('it should render Circle', () => { 24 | setup(circleProps); 25 | expect(screen.getByTestId('d3reactor-circle')).toBeInTheDocument(); 26 | }); 27 | 28 | test('Circle should have given color', () => { 29 | setup(circleProps); 30 | expect(screen.getByTestId('d3reactor-circle')).toHaveAttribute( 31 | 'fill', 32 | circleProps.color 33 | ); 34 | }); 35 | 36 | test('Circle should have given radius', () => { 37 | setup(circleProps); 38 | expect(screen.getByTestId('d3reactor-circle')).toHaveAttribute( 39 | 'r', 40 | circleProps.r 41 | ); 42 | }); 43 | 44 | test('Circle should have default radius', () => { 45 | const customProps = { 46 | cx: 10, 47 | cy: 10, 48 | color: 'red', 49 | }; 50 | setup(customProps); 51 | expect(screen.getByTestId('d3reactor-circle')).toHaveAttribute('r', '4'); 52 | }); 53 | 54 | test('Circle should have given cx attribute', () => { 55 | setup(circleProps); 56 | expect(screen.getByTestId('d3reactor-circle')).toHaveAttribute( 57 | 'cx', 58 | circleProps.cx.toString() 59 | ); 60 | }); 61 | 62 | test('Circle should have given cy attribute', () => { 63 | setup(circleProps); 64 | expect(screen.getByTestId('d3reactor-circle')).toHaveAttribute( 65 | 'cy', 66 | circleProps.cy.toString() 67 | ); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /tests/components/ContinuousAxis.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as d3 from 'd3'; 3 | import '@testing-library/jest-dom'; 4 | import { render, screen, cleanup } from '@testing-library/react'; 5 | import { Axis } from '../../src/components/ContinuousAxis'; 6 | import { ContinuousAxisProps } from '../../types'; 7 | import portfolio from '../../data/portfolio.json'; 8 | 9 | const setup = (props: ContinuousAxisProps) => { 10 | return render( 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | const mockData = portfolio; 18 | 19 | const [xMin, xMax] = d3.extent(mockData, (d) => new Date(d['date'])); 20 | const xScaleTime = d3 21 | .scaleTime() 22 | .domain([xMin ?? 0, xMax ?? 0]) 23 | .range([0, 400]); 24 | 25 | const yScale = d3 26 | .scaleLinear() 27 | .domain([-6.652160159084837, 3.742471419804005]) 28 | .range([400, 0]) 29 | .nice(); 30 | 31 | const initialProps: ContinuousAxisProps = { 32 | x: 0, 33 | y: 400, 34 | scale: xScaleTime, 35 | type: 'bottom', 36 | width: 500, 37 | height: 500, 38 | margin: { left: 80, right: 20, top: 20, bottom: 80 }, 39 | xGrid: undefined, 40 | yGrid: undefined, 41 | xTicksValue: [ 42 | new Date('2019-07-15T00:00:00.000Z'), 43 | new Date('2019-08-01T04:00:00.000Z'), 44 | new Date('2019-09-01T04:00:00.000Z'), 45 | new Date('2019-10-01T04:00:00.000Z'), 46 | new Date('2019-11-01T04:00:00.000Z'), 47 | new Date('2019-12-01T05:00:00.000Z'), 48 | new Date('2020-01-01T05:00:00.000Z'), 49 | new Date('2020-02-01T05:00:00.000Z'), 50 | new Date('2020-03-01T05:00:00.000Z'), 51 | new Date('2020-03-30T00:00:00.000Z'), 52 | ], 53 | }; 54 | 55 | const topProps: ContinuousAxisProps = { 56 | ...initialProps, 57 | type: 'top', 58 | y: 0, 59 | margin: { 60 | left: 80, 61 | right: 20, 62 | top: 80, 63 | bottom: 20, 64 | }, 65 | }; 66 | 67 | const leftProps: ContinuousAxisProps = { 68 | ...initialProps, 69 | type: 'left', 70 | y: 0, 71 | scale: yScale, 72 | xTicksValue: undefined, 73 | margin: { 74 | left: 80, 75 | right: 20, 76 | top: 20, 77 | bottom: 80, 78 | }, 79 | }; 80 | 81 | const rightProps: ContinuousAxisProps = { 82 | ...initialProps, 83 | type: 'right', 84 | x: 400, 85 | y: 0, 86 | scale: yScale, 87 | margin: { 88 | left: 20, 89 | right: 80, 90 | top: 20, 91 | bottom: 80, 92 | }, 93 | xTicksValue: undefined, 94 | }; 95 | 96 | describe('Continuous Axis test', () => { 97 | test('it should render a line for bottom axis', () => { 98 | setup(initialProps); 99 | expect(screen.getByTestId('d3reactor-continuous')).toBeVisible(); 100 | }); 101 | 102 | test('it should render a line for top axis', () => { 103 | setup(topProps); 104 | expect(screen.getByTestId('d3reactor-continuous')).toBeVisible(); 105 | }); 106 | 107 | test('it should not render a line for left axis if not ScatterPlot', () => { 108 | setup(leftProps); 109 | expect( 110 | screen.queryByTestId('d3reactor-continuous') 111 | ).not.toBeInTheDocument(); 112 | }); 113 | 114 | test('it should not render a line for right axis if not ScatterPlot', () => { 115 | setup(rightProps); 116 | expect( 117 | screen.queryByTestId('d3reactor-continuous') 118 | ).not.toBeInTheDocument(); 119 | }); 120 | 121 | test('it should render a line for left axis in ScatterPlot', () => { 122 | const yScale = d3.scaleLinear().domain([0, 4675]).range([600, 0]).nice(); 123 | const leftProps: ContinuousAxisProps = { 124 | x: 0, 125 | y: 0, 126 | scale: yScale, 127 | type: 'left', 128 | width: 700, 129 | height: 700, 130 | margin: { left: 80, right: 20, top: 20, bottom: 80 }, 131 | yGrid: true, 132 | chartType: 'scatter-plot', 133 | }; 134 | setup(leftProps); 135 | expect(screen.queryByTestId('d3reactor-continuous')).toBeInTheDocument(); 136 | }); 137 | 138 | test('it should render a line for right axis in ScatterPlot', () => { 139 | const yScale = d3.scaleLinear().domain([0, 4675]).range([600, 0]).nice(); 140 | const leftProps: ContinuousAxisProps = { 141 | x: 512, 142 | y: 0, 143 | scale: yScale, 144 | type: 'right', 145 | width: 700, 146 | height: 700, 147 | margin: { left: 20, right: 168, top: 20, bottom: 80 }, 148 | yGrid: true, 149 | chartType: 'scatter-plot', 150 | }; 151 | setup(leftProps); 152 | expect(screen.queryByTestId('d3reactor-continuous')).toBeInTheDocument(); 153 | }); 154 | 155 | test('it should compute axis line coordinates for left axis in ScatterPlot', () => { 156 | const yScale = d3.scaleLinear().domain([0, 4675]).range([600, 0]).nice(); 157 | const leftProps: ContinuousAxisProps = { 158 | x: 0, 159 | y: 0, 160 | scale: yScale, 161 | type: 'left', 162 | width: 700, 163 | height: 700, 164 | margin: { left: 80, right: 20, top: 20, bottom: 80 }, 165 | yGrid: true, 166 | chartType: 'scatter-plot', 167 | }; 168 | setup(leftProps); 169 | expect(screen.getByTestId('d3reactor-continuous')).toHaveAttribute( 170 | 'x1', 171 | '0' 172 | ); 173 | expect(screen.getByTestId('d3reactor-continuous')).toHaveAttribute( 174 | 'x2', 175 | '0' 176 | ); 177 | expect(screen.getByTestId('d3reactor-continuous')).toHaveAttribute( 178 | 'y1', 179 | '0' 180 | ); 181 | expect(screen.getByTestId('d3reactor-continuous')).toHaveAttribute( 182 | 'y2', 183 | '600' 184 | ); 185 | }); 186 | 187 | test('it should compute axis line coordinates for right axis in ScatterPlot', () => { 188 | const yScale = d3.scaleLinear().domain([0, 4675]).range([600, 0]).nice(); 189 | const leftProps: ContinuousAxisProps = { 190 | x: 512, 191 | y: 0, 192 | scale: yScale, 193 | type: 'right', 194 | width: 700, 195 | height: 700, 196 | margin: { left: 20, right: 168, top: 20, bottom: 80 }, 197 | yGrid: true, 198 | chartType: 'scatter-plot', 199 | }; 200 | setup(leftProps); 201 | expect(screen.getByTestId('d3reactor-continuous')).toHaveAttribute( 202 | 'x1', 203 | '512' 204 | ); 205 | expect(screen.getByTestId('d3reactor-continuous')).toHaveAttribute( 206 | 'x2', 207 | '512' 208 | ); 209 | expect(screen.getByTestId('d3reactor-continuous')).toHaveAttribute( 210 | 'y1', 211 | '0' 212 | ); 213 | expect(screen.getByTestId('d3reactor-continuous')).toHaveAttribute( 214 | 'y2', 215 | '600' 216 | ); 217 | }); 218 | 219 | test('it should compute axis line coordinates for bottom axis', () => { 220 | setup(initialProps); 221 | expect(screen.getByTestId('d3reactor-continuous')).toHaveAttribute( 222 | 'x1', 223 | '0' 224 | ); 225 | expect(screen.getByTestId('d3reactor-continuous')).toHaveAttribute( 226 | 'x2', 227 | '400' 228 | ); 229 | expect(screen.getByTestId('d3reactor-continuous')).toHaveAttribute( 230 | 'y1', 231 | '400' 232 | ); 233 | expect(screen.getByTestId('d3reactor-continuous')).toHaveAttribute( 234 | 'y2', 235 | '400' 236 | ); 237 | }); 238 | test('it should compute axis line coordinates for top axis', () => { 239 | setup(topProps); 240 | expect(screen.getByTestId('d3reactor-continuous')).toHaveAttribute( 241 | 'x1', 242 | '0' 243 | ); 244 | expect(screen.getByTestId('d3reactor-continuous')).toHaveAttribute( 245 | 'x2', 246 | '400' 247 | ); 248 | expect(screen.getByTestId('d3reactor-continuous')).toHaveAttribute( 249 | 'y1', 250 | '0' 251 | ); 252 | expect(screen.getByTestId('d3reactor-continuous')).toHaveAttribute( 253 | 'y2', 254 | '0' 255 | ); 256 | }); 257 | 258 | test('it should render formatted date tick text', () => { 259 | setup(initialProps); 260 | expect(screen.getByText('10/1/2019')).toBeVisible(); 261 | expect( 262 | screen.queryByText('2019-10-01T04:00:00.000Z') 263 | ).not.toBeInTheDocument(); 264 | }); 265 | 266 | test('it should filter tick text elements number of horizontal axis based on its width', () => { 267 | setup(initialProps); 268 | expect(screen.queryByText('12/1/2019')).not.toBeInTheDocument(); 269 | expect(screen.queryAllByTestId('d3reactor-ticktext')).toHaveLength(2); 270 | cleanup(); 271 | setup({ ...initialProps, width: 1000, height: 1000 }); 272 | expect(screen.queryAllByTestId('d3reactor-ticktext')).toHaveLength(8); 273 | }); 274 | 275 | test('it should filter tick text elements number of vertical axis based on its width', () => { 276 | setup(leftProps); 277 | expect(screen.queryAllByTestId('d3reactor-ticktext')).toHaveLength(6); 278 | expect(screen.queryByText('1')).not.toBeInTheDocument(); 279 | cleanup(); 280 | const yScaleUpdated = d3 281 | .scaleLinear() 282 | .domain([-6.652160159084837, 3.742471419804005]) 283 | .range([900, 0]) 284 | .nice(); 285 | const leftPropsResized = { 286 | ...leftProps, 287 | width: 1000, 288 | height: 1000, 289 | scale: yScaleUpdated, 290 | }; 291 | setup(leftPropsResized); 292 | expect(screen.queryAllByTestId('d3reactor-ticktext')).toHaveLength(12); 293 | expect(screen.queryByText('1')).toBeInTheDocument(); 294 | }); 295 | 296 | test('it should filter tick text elements number based on height for right axis', () => { 297 | setup(rightProps); 298 | expect(screen.queryAllByTestId('d3reactor-ticktext')).toHaveLength(6); 299 | expect(screen.queryByText('1')).not.toBeInTheDocument(); 300 | cleanup(); 301 | const yScaleUpdated = d3 302 | .scaleLinear() 303 | .domain([-6.652160159084837, 3.742471419804005]) 304 | .range([900, 0]) 305 | .nice(); 306 | const rightPropsResized = { 307 | ...leftProps, 308 | width: 1000, 309 | height: 1000, 310 | scale: yScaleUpdated, 311 | }; 312 | setup(rightPropsResized); 313 | expect(screen.queryAllByTestId('d3reactor-ticktext')).toHaveLength(12); 314 | expect(screen.queryByText('1')).toBeInTheDocument(); 315 | }); 316 | 317 | test('it should compute tick text position for bottom axis', () => { 318 | setup(initialProps); 319 | expect(screen.getByText('10/1/2019')).toHaveAttribute( 320 | 'transform', 321 | 'translate(120.46332046332047, 418)' 322 | ); 323 | expect(screen.getByText('1/1/2020')).toHaveAttribute( 324 | 'transform', 325 | 'translate(262.5482625482625, 418)' 326 | ); 327 | cleanup(); 328 | const updatedXScaleTime = d3 329 | .scaleTime() 330 | .domain([xMin ?? 0, xMax ?? 0]) 331 | .range([0, 900]); 332 | setup({ 333 | ...initialProps, 334 | width: 1000, 335 | height: 1000, 336 | scale: updatedXScaleTime, 337 | y: 900, 338 | }); 339 | expect(screen.getByText('2/1/2020')).toHaveAttribute( 340 | 'transform', 341 | 'translate(698.4555984555984, 918)' 342 | ); 343 | }); 344 | 345 | test('it should compute tick text position for top axis', () => { 346 | setup(topProps); 347 | expect(screen.getByText('10/1/2019')).toHaveAttribute( 348 | 'transform', 349 | 'translate(120.46332046332047, -8)' 350 | ); 351 | expect(screen.getByText('1/1/2020')).toHaveAttribute( 352 | 'transform', 353 | 'translate(262.5482625482625, -8)' 354 | ); 355 | cleanup(); 356 | const updatedXScaleTime = d3 357 | .scaleTime() 358 | .domain([xMin ?? 0, xMax ?? 0]) 359 | .range([0, 900]); 360 | const topPropsResized: ContinuousAxisProps = { 361 | ...initialProps, 362 | type: 'top', 363 | y: 0, 364 | margin: { 365 | left: 80, 366 | right: 20, 367 | top: 80, 368 | bottom: 20, 369 | }, 370 | width: 1000, 371 | height: 1000, 372 | scale: updatedXScaleTime, 373 | }; 374 | setup(topPropsResized); 375 | expect(screen.getByText('2/1/2020')).toHaveAttribute( 376 | 'transform', 377 | 'translate(698.4555984555984, -8)' 378 | ); 379 | }); 380 | 381 | test('it should compute tick text position for left axis', () => { 382 | setup(leftProps); 383 | expect(screen.getByText('-6')).toHaveAttribute( 384 | 'transform', 385 | 'translate(-12, 363.6363636363636)' 386 | ); 387 | expect(screen.getByText('0')).toHaveAttribute( 388 | 'transform', 389 | 'translate(-12, 145.45454545454547)' 390 | ); 391 | }); 392 | 393 | test('it should compute tick text position for right axis', () => { 394 | setup(rightProps); 395 | expect(screen.getByText('-6')).toHaveAttribute( 396 | 'transform', 397 | 'translate(412, 363.6363636363636)' 398 | ); 399 | expect(screen.getByText('0')).toHaveAttribute( 400 | 'transform', 401 | 'translate(412, 145.45454545454547)' 402 | ); 403 | }); 404 | 405 | test('it should not display gridlines for bottom axis by default', () => { 406 | setup(initialProps); 407 | expect(screen.queryAllByTestId('d3reactor-gridline')).toHaveLength(0); 408 | }); 409 | 410 | test('it should not display gridlines for left axis by default', () => { 411 | setup(leftProps); 412 | expect(screen.queryAllByTestId('d3reactor-gridline')).toHaveLength(0); 413 | }); 414 | 415 | test('it should not display gridlines for right axis by default', () => { 416 | setup(rightProps); 417 | expect(screen.queryAllByTestId('d3reactor-gridline')).toHaveLength(0); 418 | }); 419 | 420 | test('it should not display gridlines for top axis by default', () => { 421 | setup(topProps); 422 | expect(screen.queryAllByTestId('d3reactor-gridline')).toHaveLength(0); 423 | }); 424 | 425 | test('it should display gridlines for bottom axis when enabled', () => { 426 | setup({ ...initialProps, xGrid: true }); 427 | const elements = screen.queryAllByTestId('d3reactor-gridline'); 428 | expect(elements).toHaveLength(10); 429 | expect(elements[0]).toHaveAttribute('x1', '0'); 430 | expect(elements[0]).toHaveAttribute('x2', '0'); 431 | expect(elements[0]).toHaveAttribute('y1', '0'); 432 | expect(elements[0]).toHaveAttribute('y2', '-400'); 433 | expect(elements[9]).toHaveAttribute('x1', '400'); 434 | expect(elements[9]).toHaveAttribute('x2', '400'); 435 | expect(elements[9]).toHaveAttribute('y1', '0'); 436 | expect(elements[9]).toHaveAttribute('y2', '-400'); 437 | }); 438 | 439 | test('it should display gridlines for top axis when enabled', () => { 440 | setup({ 441 | ...topProps, 442 | xGrid: true, 443 | }); 444 | const elements = screen.queryAllByTestId('d3reactor-gridline'); 445 | expect(elements).toHaveLength(10); 446 | expect(elements[0]).toHaveAttribute('x1', '0'); 447 | expect(elements[0]).toHaveAttribute('x2', '0'); 448 | expect(elements[0]).toHaveAttribute('y1', '0'); 449 | expect(elements[0]).toHaveAttribute('y2', '400'); 450 | expect(elements[9]).toHaveAttribute('x1', '400'); 451 | expect(elements[9]).toHaveAttribute('x2', '400'); 452 | expect(elements[9]).toHaveAttribute('y1', '0'); 453 | expect(elements[9]).toHaveAttribute('y2', '400'); 454 | }); 455 | 456 | test('it should display gridlines for left axis when enabled', () => { 457 | setup({ ...leftProps, yGrid: true }); 458 | const elements = screen.queryAllByTestId('d3reactor-gridline'); 459 | expect(elements).toHaveLength(12); 460 | expect(elements[0]).toHaveAttribute('x1', '0'); 461 | expect(elements[0]).toHaveAttribute('x2', '400'); 462 | expect(elements[0]).toHaveAttribute('y1', '400'); 463 | expect(elements[0]).toHaveAttribute('y2', '400'); 464 | expect(elements[9]).toHaveAttribute('x1', '0'); 465 | expect(elements[9]).toHaveAttribute('x2', '400'); 466 | expect(elements[9]).toHaveAttribute('y1', '72.7272727272727'); 467 | expect(elements[9]).toHaveAttribute('y2', '72.7272727272727'); 468 | }); 469 | 470 | test('it should display gridlines for right axis when enabled', () => { 471 | setup({ ...rightProps, yGrid: true }); 472 | const elements = screen.queryAllByTestId('d3reactor-gridline'); 473 | expect(elements).toHaveLength(12); 474 | expect(elements[0]).toHaveAttribute('x1', '0'); 475 | expect(elements[0]).toHaveAttribute('x2', '-400'); 476 | expect(elements[0]).toHaveAttribute('y1', '400'); 477 | expect(elements[0]).toHaveAttribute('y2', '400'); 478 | expect(elements[9]).toHaveAttribute('x1', '0'); 479 | expect(elements[9]).toHaveAttribute('x2', '-400'); 480 | expect(elements[9]).toHaveAttribute('y1', '72.7272727272727'); 481 | expect(elements[9]).toHaveAttribute('y2', '72.7272727272727'); 482 | }); 483 | }); 484 | -------------------------------------------------------------------------------- /tests/components/DiscreteAxis.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as d3 from 'd3'; 3 | import '@testing-library/jest-dom'; 4 | import { render, screen, cleanup } from '@testing-library/react'; 5 | import { DiscreteAxis } from '../../src/components/DiscreteAxis'; 6 | import { DiscreteAxisProps } from '../../types'; 7 | 8 | // tests for top axis tick text position and transformation not written 9 | // functionality must be implemented in component first 10 | // write test for useEffect and useState to add more margin 11 | 12 | const portfolio = [ 13 | { 14 | date: '2019-07-22', 15 | marketvalue: 96446.730245, 16 | value: -0.016192713510560883, 17 | }, 18 | { 19 | date: '2019-07-23', 20 | marketvalue: 96483.764726, 21 | value: 0.02219996607383714, 22 | }, 23 | { 24 | date: '2019-07-24', 25 | marketvalue: 96579.495121, 26 | value: 0.12144116731337547, 27 | }, 28 | { 29 | date: '2019-07-25', 30 | marketvalue: 96691.287123, 31 | value: 0.2373330172054904, 32 | }, 33 | { 34 | date: '2019-07-26', 35 | marketvalue: 96508.730766, 36 | value: 0.04808160794729189, 37 | }, 38 | { 39 | date: '2019-07-29', 40 | marketvalue: 96565.511343, 41 | value: 0.10694454973870622, 42 | }, 43 | { 44 | date: '2019-07-30', 45 | marketvalue: 96591.277652, 46 | value: 0.13365580959164142, 47 | }, 48 | { 49 | date: '2019-07-31', 50 | marketvalue: 96607.294778, 51 | value: 0.15026034595279442, 52 | }, 53 | ]; 54 | const unemployment = [ 55 | { 56 | division: 'Bethesda-Rockville-Frederick, MD Met Div', 57 | date: '2000-02-01T00:00:00.000Z', 58 | unemployment: 2.6, 59 | }, 60 | { 61 | division: 'Bethesda-Rockville-Frederick, MD Met Div', 62 | date: '2000-03-01T00:00:00.000Z', 63 | unemployment: 2.6, 64 | }, 65 | { 66 | division: 'Bethesda-Rockville-Frederick, MD Met Div', 67 | date: '2000-04-01T00:00:00.000Z', 68 | unemployment: 2.6, 69 | }, 70 | { 71 | division: 'Bethesda-Rockville-Frederick, MD Met Div', 72 | date: '2000-05-01T00:00:00.000Z', 73 | unemployment: 2.7, 74 | }, 75 | { 76 | division: 'Bethesda-Rockville-Frederick, MD Met Div', 77 | date: '2000-06-01T00:00:00.000Z', 78 | unemployment: 2.7, 79 | }, 80 | { 81 | division: 'Bethesda-Rockville-Frederick, MD Met Div', 82 | date: '2000-07-01T00:00:00.000Z', 83 | unemployment: 2.7, 84 | }, 85 | { 86 | division: 'Bethesda-Rockville-Frederick, MD Met Div', 87 | date: '2000-08-01T00:00:00.000Z', 88 | unemployment: 2.6, 89 | }, 90 | { 91 | division: 'Bethesda-Rockville-Frederick, MD Met Div', 92 | date: '2000-09-01T00:00:00.000Z', 93 | unemployment: 2.6, 94 | }, 95 | ]; 96 | 97 | const unemploymentLongText = [ 98 | { 99 | division: 'Bethesda-Rockville-Frederick, MD Met Div', 100 | date: '2000-01-01T00:00:00.000Z', 101 | unemployment: 2.6, 102 | }, 103 | { 104 | division: 'Boston-Cambridge-Quincy, MA NECTA Div', 105 | date: '2003-01-01T00:00:00.000Z', 106 | unemployment: 5.3, 107 | }, 108 | { 109 | division: 'Brockton-Bridgewater-Easton, MA NECTA Div', 110 | date: '2005-10-01T00:00:00.000Z', 111 | unemployment: 5.4, 112 | }, 113 | { 114 | division: 'Camden, NJ Met Div', 115 | date: '2008-01-01T00:00:00.000Z', 116 | unemployment: 4.7, 117 | }, 118 | { 119 | division: 'Chicago-Joliet-Naperville, IL Met Div', 120 | date: '2005-11-01T00:00:00.000Z', 121 | unemployment: 5.5, 122 | }, 123 | ]; 124 | 125 | const mockSetState = jest.fn(); 126 | 127 | const xAccessor = (d: any) => d.date; 128 | 129 | const scale = d3 130 | .scaleBand() 131 | .paddingInner(0.1) 132 | .paddingOuter(0.1) 133 | .domain(portfolio.map(xAccessor)) 134 | .range([0, 700]); 135 | const props: DiscreteAxisProps = { 136 | x: 0, 137 | y: 626, 138 | scale, 139 | type: 'bottom', 140 | width: 800, 141 | margin: { 142 | left: 20, 143 | right: 80, 144 | top: 20, 145 | bottom: 154, 146 | }, 147 | data: portfolio, 148 | xAccessor, 149 | setTickMargin: mockSetState, 150 | }; 151 | 152 | const setup = (props: DiscreteAxisProps) => { 153 | return render( 154 | 155 | 156 | 157 | ); 158 | }; 159 | 160 | describe('Discrete Axis test', () => { 161 | test('it should render a line for bottom axis', () => { 162 | setup(props); 163 | expect(screen.getByTestId('discrete-axis')).toBeVisible(); 164 | }); 165 | 166 | test('it should compute axis line coordinates for bottom axis', () => { 167 | setup(props); 168 | expect(screen.getByTestId('discrete-axis')).toHaveAttribute('x1', '0'); 169 | expect(screen.getByTestId('discrete-axis')).toHaveAttribute('x2', '700'); 170 | expect(screen.getByTestId('discrete-axis')).toHaveAttribute('y1', '626'); 171 | expect(screen.getByTestId('discrete-axis')).toHaveAttribute('y2', '626'); 172 | }); 173 | 174 | test('it should render a line for top axis', () => { 175 | const updatedProps: DiscreteAxisProps = { 176 | ...props, 177 | y: 0, 178 | type: 'top', 179 | margin: { 180 | left: 20, 181 | right: 80, 182 | top: 80, 183 | bottom: 94, 184 | }, 185 | }; 186 | setup(updatedProps); 187 | expect(screen.getByTestId('discrete-axis')).toBeVisible(); 188 | }); 189 | 190 | test('it should compute axis line coordinates for top axis', () => { 191 | const updatedProps: DiscreteAxisProps = { 192 | ...props, 193 | y: 0, 194 | type: 'top', 195 | margin: { 196 | left: 20, 197 | right: 80, 198 | top: 80, 199 | bottom: 94, 200 | }, 201 | }; 202 | setup(updatedProps); 203 | expect(screen.getByTestId('discrete-axis')).toHaveAttribute('x1', '0'); 204 | expect(screen.getByTestId('discrete-axis')).toHaveAttribute('x2', '700'); 205 | expect(screen.getByTestId('discrete-axis')).toHaveAttribute('y1', '0'); 206 | expect(screen.getByTestId('discrete-axis')).toHaveAttribute('y2', '0'); 207 | }); 208 | 209 | test("it should not format tick text if it's less than 10 characters", () => { 210 | setup(props); 211 | expect(screen.queryAllByTestId('d3reactor-ticktext')[0]).toHaveTextContent( 212 | '2019-07-22' 213 | ); 214 | }); 215 | 216 | test("it should format tick text if it's more than 10 characters and represents date", () => { 217 | const updatedScale = d3 218 | .scaleBand() 219 | .paddingInner(0.1) 220 | .paddingOuter(0.1) 221 | .domain(unemployment.map(xAccessor)) 222 | .range([0, 700]); 223 | const updatedProps: DiscreteAxisProps = { 224 | ...props, 225 | y: 666, 226 | margin: { 227 | left: 20, 228 | right: 80, 229 | top: 20, 230 | bottom: 114, 231 | }, 232 | scale: updatedScale, 233 | data: unemployment, 234 | }; 235 | setup(updatedProps); 236 | expect(screen.queryAllByTestId('d3reactor-ticktext')[0]).toHaveTextContent( 237 | '2/1/2000' 238 | ); 239 | expect( 240 | screen.queryAllByTestId('d3reactor-ticktext')[0] 241 | ).not.toHaveTextContent('2000-02-01T00:00:00.000Z'); 242 | }); 243 | 244 | test('it should rotate tick text on bottom axis if text width is more than width of rectangles', () => { 245 | setup(props); 246 | const elements = screen.queryAllByTestId('d3reactor-ticktext'); 247 | expect(elements[0]).toHaveAttribute( 248 | 'transform', 249 | 'translate(51.03086419753081, 661), rotate(-90)' 250 | ); 251 | expect(elements[2]).toHaveAttribute( 252 | 'transform', 253 | 'translate(223.87037037037032, 661), rotate(-90)' 254 | ); 255 | }); 256 | 257 | test('it should correctly position tick text on bottom axis', () => { 258 | const updatedScale = d3 259 | .scaleBand() 260 | .paddingInner(0.1) 261 | .paddingOuter(0.1) 262 | .domain(portfolio.map(xAccessor)) 263 | .range([0, 1100]); 264 | setup({ 265 | ...props, 266 | y: 1066, 267 | width: 1200, 268 | margin: { 269 | left: 20, 270 | right: 80, 271 | top: 20, 272 | bottom: 114, 273 | }, 274 | scale: updatedScale, 275 | }); 276 | const elements = screen.queryAllByTestId('d3reactor-ticktext'); 277 | expect(elements[0]).toHaveAttribute( 278 | 'transform', 279 | 'translate(74.69135802469125, 1080)' 280 | ); 281 | expect(elements[4]).toHaveAttribute( 282 | 'transform', 283 | 'translate(617.9012345679012, 1080)' 284 | ); 285 | }); 286 | 287 | test('it should correctly position tick text on top axis', () => { 288 | const updatedScale = d3 289 | .scaleBand() 290 | .paddingInner(0.1) 291 | .paddingOuter(0.1) 292 | .domain(portfolio.map(xAccessor)) 293 | .range([0, 1100]); 294 | setup({ 295 | ...props, 296 | y: 0, 297 | width: 1200, 298 | type: 'top', 299 | scale: updatedScale, 300 | margin: { 301 | left: 20, 302 | right: 80, 303 | top: 80, 304 | bottom: 54, 305 | }, 306 | }); 307 | const elements = screen.queryAllByTestId('d3reactor-ticktext'); 308 | expect(elements[0]).toHaveAttribute( 309 | 'transform', 310 | 'translate(74.69135802469125, -7)' 311 | ); 312 | expect(elements[4]).toHaveAttribute( 313 | 'transform', 314 | 'translate(617.9012345679012, -7)' 315 | ); 316 | }); 317 | 318 | test("it should slice text if it's longer than 10 characters and wider than rectangle", () => { 319 | const updatedScale = d3 320 | .scaleBand() 321 | .paddingInner(0.1) 322 | .paddingOuter(0.1) 323 | .domain(unemploymentLongText.map(xAccessor)) 324 | .range([0, 1900]); 325 | const updatedProps: DiscreteAxisProps = { 326 | ...props, 327 | y: 1066, 328 | width: 2000, 329 | type: 'bottom', 330 | scale: updatedScale, 331 | margin: { 332 | left: 20, 333 | right: 80, 334 | top: 20, 335 | bottom: 114, 336 | }, 337 | data: unemploymentLongText, 338 | xAccessor: (d: any) => d.division, 339 | }; 340 | setup(updatedProps); 341 | expect( 342 | screen.queryByText('Bethesda-Rockville-Frederick, MD Met Div') 343 | ).toBeInTheDocument(); 344 | cleanup(); 345 | const resizedScale = d3 346 | .scaleBand() 347 | .paddingInner(0.1) 348 | .paddingOuter(0.1) 349 | .domain(unemploymentLongText.map(xAccessor)) 350 | .range([0, 600]); 351 | setup({ 352 | ...updatedProps, 353 | y: 526, 354 | width: 700, 355 | scale: resizedScale, 356 | margin: { 357 | left: 20, 358 | right: 80, 359 | top: 20, 360 | bottom: 154, 361 | }, 362 | }); 363 | expect( 364 | screen.queryByText('Bethesda-Rockville-Frederick, MD Met Div') 365 | ).not.toBeInTheDocument(); 366 | expect(screen.queryByText('Bethesda-R')).toBeInTheDocument(); 367 | }); 368 | }); 369 | -------------------------------------------------------------------------------- /tests/components/Line.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import React from 'react'; 3 | import { render, screen } from '@testing-library/react'; 4 | import { Line } from '../../src/components/Line'; 5 | import { LineProps } from '../../types'; 6 | 7 | const mockedData = 8 | 'M0,594.1063333333333L290.1190476190476,6.0830000000000055L580.2380952380952,491.5643333333333L638.2619047619048,815.4116666666666L696.2857142857142,807.301L754.3095238095239,718.9526666666667L812.3333333333333,581.9403333333333L870.3571428571429,689.4066666666666L928.3809523809523,730.2496666666667L986.4047619047619,806.1423333333333L1044.4285714285713,844.3783333333333L1102.452380952381,684.1926666666667L1160.4761904761904,643.6393333333333L1218.5,767.0373333333333'; 9 | const stroke = 'rgb(157, 200, 226)'; 10 | const LineProps: LineProps = { 11 | fill: 'none', 12 | stroke: stroke, 13 | strokeWidth: '1px', 14 | d: mockedData, 15 | }; 16 | const svg = document.createElement('svg'); 17 | 18 | const setup = () => { 19 | return render( 20 | 21 | 22 | 23 | ); 24 | }; 25 | 26 | describe('Line test', () => { 27 | test('it should render Line', () => { 28 | setup(); 29 | expect(screen.getByTestId('d3reactor-line')).toBeInTheDocument(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /tests/global-setup.js: -------------------------------------------------------------------------------- 1 | module.exports = async () => { 2 | process.env.TZ = 'UTC'; 3 | }; 4 | -------------------------------------------------------------------------------- /tests/global-setup.test.ts: -------------------------------------------------------------------------------- 1 | describe('Timezones', () => { 2 | it('should always be UTC', () => { 3 | expect(new Date().getTimezoneOffset()).toBe(0); 4 | }); 5 | }); -------------------------------------------------------------------------------- /tests/index.cypress.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Test from './Test'; 4 | 5 | ReactDOM.render( 6 | <> 7 | 8 | , 9 | document.getElementById('root') 10 | ); 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": ["dom", "dom.iterable", "es6"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "commonjs", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": false, 16 | "declaration": true, 17 | "declarationDir": "dist/types", 18 | "noEmit": false, 19 | "noEmitOnError": true, 20 | "jsx": "react", 21 | "outDir": "./dist/" 22 | }, 23 | "include": ["src", "types.ts", "tests/Test.tsx","tests"], 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.webpack.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "esModuleInterop": true 6 | } 7 | } -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3'; 2 | import React from 'react'; 3 | export interface Data { 4 | [key: string]: any; 5 | } 6 | 7 | export interface toolTipState { 8 | cursorX: number; 9 | cursorY: number; 10 | distanceFromTop: number; 11 | distanceFromRight: number; 12 | distanceFromLeft: number; 13 | data: any; 14 | } 15 | 16 | export interface ScatterPlotProps { 17 | theme?: 'light' | 'dark'; 18 | data: Data[]; 19 | height?: T; 20 | width?: T; 21 | xKey: string; 22 | xDataType?: 'date' | 'number'; 23 | yKey: string; 24 | groupBy?: string; 25 | xAxis?: 'top' | 'bottom' | false; 26 | yAxis?: 'left' | 'right' | false; 27 | xGrid?: boolean; 28 | yGrid?: boolean; 29 | xAxisLabel?: string; 30 | yAxisLabel?: string; 31 | legend?: LegendPos; 32 | legendLabel?: string; 33 | chartType?: 34 | | 'scatter-plot' 35 | | 'line-chart' 36 | | 'area-chart' 37 | | 'bar-chart' 38 | | 'pie-chart' 39 | | undefined; 40 | colorScheme?: 41 | | 'schemeRdYlGn' 42 | | 'schemeRdYlBu' 43 | | 'schemeRdGy' 44 | | 'schemeRdBu' 45 | | 'schemePuOr' 46 | | 'schemePiYG' 47 | | 'schemePRGn' 48 | | 'schemeBrBG' 49 | | 'schemeReds' 50 | | 'schemePurples' 51 | | 'schemeOranges' 52 | | 'schemeGreys' 53 | | 'schemeGreens' 54 | | 'schemeBlues' 55 | | 'schemeSpectral'; 56 | tooltipVisible?: boolean; 57 | } 58 | 59 | export interface BarChartProps { 60 | theme?: 'light' | 'dark'; 61 | data: Data[]; 62 | height?: T; 63 | width?: T; 64 | xKey: string; 65 | yKey: string; 66 | groupBy?: string; 67 | xAxis?: 'top' | 'bottom' | false; 68 | yAxis?: 'left' | 'right' | false; 69 | yGrid?: boolean; 70 | xAxisLabel?: string; 71 | yAxisLabel?: string; 72 | legend?: LegendPos; 73 | legendLabel?: string; 74 | chartType?: 75 | | 'scatter-plot' 76 | | 'line-chart' 77 | | 'area-chart' 78 | | 'bar-chart' 79 | | 'pie-chart' 80 | | undefined; 81 | colorScheme?: 82 | | 'schemeRdYlGn' 83 | | 'schemeRdYlBu' 84 | | 'schemeRdGy' 85 | | 'schemeRdBu' 86 | | 'schemePuOr' 87 | | 'schemePiYG' 88 | | 'schemePRGn' 89 | | 'schemeBrBG' 90 | | 'schemeReds' 91 | | 'schemePurples' 92 | | 'schemeOranges' 93 | | 'schemeGreys' 94 | | 'schemeGreens' 95 | | 'schemeBlues' 96 | | 'schemeSpectral'; 97 | tooltipVisible?: boolean; 98 | } 99 | 100 | export interface LineChartProps { 101 | theme?: 'light' | 'dark'; 102 | data: Data[]; 103 | dataTestId?: string; 104 | height?: T; 105 | width?: T; 106 | xKey: string; 107 | xDataType?: 'date' | 'number'; 108 | yKey: string; 109 | groupBy?: string; 110 | xAxis?: 'top' | 'bottom' | false; 111 | yAxis?: 'left' | 'right' | false; 112 | xGrid?: boolean; 113 | yGrid?: boolean; 114 | xAxisLabel?: string; 115 | yAxisLabel?: string; 116 | legend?: LegendPos; 117 | legendLabel?: string; 118 | chartType?: 119 | | 'scatter-plot' 120 | | 'line-chart' 121 | | 'area-chart' 122 | | 'bar-chart' 123 | | 'pie-chart' 124 | | undefined; 125 | colorScheme?: 126 | | 'schemeRdYlGn' 127 | | 'schemeRdYlBu' 128 | | 'schemeRdGy' 129 | | 'schemeRdBu' 130 | | 'schemePuOr' 131 | | 'schemePiYG' 132 | | 'schemePRGn' 133 | | 'schemeBrBG' 134 | | 'schemeReds' 135 | | 'schemePurples' 136 | | 'schemeOranges' 137 | | 'schemeGreys' 138 | | 'schemeGreens' 139 | | 'schemeBlues' 140 | | 'schemeSpectral'; 141 | tooltipVisible?: boolean; 142 | } 143 | 144 | export interface AreaChartProps { 145 | theme?: 'light' | 'dark'; 146 | data: Data[]; 147 | height?: T; 148 | width?: T; 149 | xKey: string; 150 | xDataType?: 'date' | 'number'; 151 | yKey: string; 152 | groupBy?: string; 153 | xAxis?: 'top' | 'bottom' | false; 154 | yAxis?: 'left' | 'right' | false; 155 | xGrid?: boolean; 156 | yGrid?: boolean; 157 | xAxisLabel?: string; 158 | yAxisLabel?: string; 159 | legend?: LegendPos; 160 | legendLabel?: string; 161 | chartType?: 162 | | 'scatter-plot' 163 | | 'line-chart' 164 | | 'area-chart' 165 | | 'bar-chart' 166 | | 'pie-chart' 167 | | undefined; 168 | colorScheme?: 169 | | 'schemeRdYlGn' 170 | | 'schemeRdYlBu' 171 | | 'schemeRdGy' 172 | | 'schemeRdBu' 173 | | 'schemePuOr' 174 | | 'schemePiYG' 175 | | 'schemePRGn' 176 | | 'schemeBrBG' 177 | | 'schemeReds' 178 | | 'schemePurples' 179 | | 'schemeOranges' 180 | | 'schemeGreys' 181 | | 'schemeGreens' 182 | | 'schemeBlues' 183 | | 'schemeSpectral'; 184 | tooltipVisible?: boolean; 185 | } 186 | 187 | export interface PieChartProps { 188 | theme?: 'light' | 'dark'; 189 | data: any; 190 | innerRadius?: number | string | undefined; 191 | label: string; 192 | legend?: LegendPos; 193 | legendLabel?: string; 194 | outerRadius?: number | string | undefined; 195 | pieLabel?: boolean; 196 | value: string; 197 | chartType?: 198 | | 'scatter-plot' 199 | | 'line-chart' 200 | | 'area-chart' 201 | | 'bar-chart' 202 | | 'pie-chart' 203 | | undefined; 204 | colorScheme?: 205 | | 'schemeRdYlGn' 206 | | 'schemeRdYlBu' 207 | | 'schemeRdGy' 208 | | 'schemeRdBu' 209 | | 'schemePuOr' 210 | | 'schemePiYG' 211 | | 'schemePRGn' 212 | | 'schemeBrBG' 213 | | 'schemeReds' 214 | | 'schemePurples' 215 | | 'schemeOranges' 216 | | 'schemeGreys' 217 | | 'schemeGreens' 218 | | 'schemeBlues' 219 | | 'schemeSpectral'; 220 | tooltipVisible?: boolean; 221 | } 222 | 223 | export interface PieChartBodyProps { 224 | theme?: 'light' | 'dark'; 225 | data: any; 226 | height: number; 227 | width: number; 228 | innerRadius: number; 229 | outerRadius: number; 230 | value: string; 231 | label: string; 232 | legend?: boolean; 233 | colorScheme?: string; 234 | } 235 | 236 | export interface Margin { 237 | top: number; 238 | right: number; 239 | bottom: number; 240 | left: number; 241 | } 242 | 243 | export interface ContinuousAxisProps { 244 | theme?: 'light' | 'dark'; 245 | dataTestId?: string; 246 | x: number; 247 | y: number; 248 | xGrid?: boolean; 249 | yGrid?: boolean; 250 | scale: 251 | | d3.ScaleLinear 252 | | d3.ScaleTime; 253 | type: 'top' | 'right' | 'bottom' | 'left'; 254 | height: number; 255 | width: number; 256 | margin: Margin; 257 | xTicksValue?: any; 258 | chartType?: 259 | | 'bar-chart' 260 | | 'line-chart' 261 | | 'area-chart' 262 | | 'scatter-plot' 263 | | 'pie-chart'; 264 | } 265 | 266 | export interface DiscreteAxisProps { 267 | theme?: 'light' | 'dark'; 268 | dataTestId?: string; 269 | x: number; 270 | y: number; 271 | xGrid?: boolean; 272 | yGrid?: boolean; 273 | scale: d3.ScaleBand; 274 | type: 'top' | 'right' | 'bottom' | 'left'; 275 | width: number; 276 | margin: Margin; 277 | data: Data[]; 278 | xAccessor: (d: Data) => string; 279 | setTickMargin: React.Dispatch; 280 | chartType?: 281 | | 'bar-chart' 282 | | 'line-chart' 283 | | 'area-chart' 284 | | 'scatter-plot' 285 | | 'pie-chart'; 286 | } 287 | 288 | export interface TooltipProps { 289 | dataTestId?: string; 290 | theme: 'light' | 'dark'; 291 | chartType?: 292 | | 'bar-chart' 293 | | 'line-chart' 294 | | 'area-chart' 295 | | 'scatter-plot' 296 | | 'pie-chart'; 297 | data: any; 298 | xAccessor?: xAccessorFunc; 299 | yAccessor?: yAccessorFunc; 300 | cursorX: number; 301 | cursorY: number; 302 | distanceFromTop: number; 303 | distanceFromRight: number; 304 | distanceFromLeft: number; 305 | xKey?: string; 306 | yKey?: string; 307 | } 308 | 309 | export interface CircleProps { 310 | cx: number; 311 | cy: number; 312 | r?: string; 313 | color: string; 314 | } 315 | 316 | export interface RectangleProps { 317 | data: Data; 318 | dataTestId?: string; 319 | x: number | undefined; 320 | y: number; 321 | width: number; 322 | height: number; 323 | margin: Margin; 324 | cWidth: number; 325 | fill: string; 326 | setTooltip?: React.Dispatch; 327 | } 328 | 329 | export interface LineProps { 330 | fill?: string; 331 | stroke: string; 332 | strokeWidth?: string; 333 | d: string | undefined; 334 | id?: string | number; 335 | } 336 | 337 | export interface ArcProps { 338 | data: Record; 339 | dataTestId?: string; 340 | key: string; 341 | fill: string; 342 | stroke: string; 343 | strokeWidth: string; 344 | d: string | undefined; 345 | id?: string | number; 346 | cellCenter?: { cx: number; cy: number; tooltipData: Data }; 347 | setTooltip?: React.Dispatch; 348 | margin: Margin; 349 | cWidth: number; 350 | } 351 | 352 | // eslint-disable-next-line import/export 353 | export interface VoronoiProps { 354 | fill: string; 355 | stroke: string; 356 | opacity: number; 357 | d: string | undefined; 358 | cellCenter: { cx: number; cy: number; tooltipData: Data }; 359 | data?: any; 360 | setTooltip?: React.Dispatch; 361 | margin: Margin; 362 | cWidth: number; 363 | } 364 | 365 | export type ColorScale = d3.ScaleOrdinal; 366 | 367 | export interface ColorLegendProps { 368 | theme?: 'light' | 'dark'; 369 | colorScale: ColorScale; 370 | dataTestId?: string; 371 | tickSpacing?: number; 372 | circleRadius: number; 373 | tickTextOffset?: number; 374 | legendLabel?: string; 375 | labels: string[]; 376 | legendPosition: LegendPos; 377 | legendWidth: number; 378 | legendHeight: number; 379 | xPosition?: number; 380 | yPosition?: number; 381 | setLegendOffset: React.Dispatch; 382 | margin: Margin; 383 | xAxisPosition?: 'top' | 'bottom' | false; 384 | yAxisPosition?: 'left' | 'right' | false; 385 | cWidth: number; 386 | cHeight: number; 387 | EXTRA_LEGEND_MARGIN: number; 388 | fontSize?: number; 389 | } 390 | 391 | export type LegendPos = 392 | | boolean 393 | | 'top' 394 | | 'bottom' 395 | | 'left' 396 | | 'right' 397 | | 'top-left' 398 | | 'top-right' 399 | | 'bottom-left' 400 | | 'bottom-right' 401 | | 'left-bottom' 402 | | 'right-bottom' 403 | | 'left-top' 404 | | 'right-top'; 405 | 406 | export type ScaleFunc = 407 | | d3.ScaleLinear 408 | | d3.ScaleTime; 409 | 410 | export type xAccessorFunc = (d: any) => number | Date; 411 | 412 | export type yAccessorFunc = (d: any, i?: number) => number; 413 | 414 | export type Domain = number | Date | undefined; 415 | 416 | export interface VoronoiBody { 417 | data: Data; 418 | voronoi: d3.Voronoi; 419 | xScale: ScaleFunc; 420 | yScale: ScaleFunc; 421 | xAccessor: xAccessorFunc; 422 | yAccessor: yAccessorFunc; 423 | setTooltip: React.Dispatch | undefined; 424 | margin: Margin; 425 | cWidth: number; 426 | } 427 | export type GroupAccessorFunc = (d: any) => number | Date; 428 | -------------------------------------------------------------------------------- /webpack.common.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import webpack from 'webpack'; 3 | 4 | const webpackconfiguration: webpack.Configuration = { 5 | module: { 6 | rules: [ 7 | { 8 | test: /\.tsx?$/, 9 | loader: 'ts-loader', 10 | exclude: /node_modules/, 11 | }, 12 | { 13 | test: /.(css)$/, 14 | exclude: /node_modules/, 15 | use: ['style-loader', 'css-loader'], 16 | }, 17 | ], 18 | }, 19 | resolve: { 20 | extensions: ['.tsx', '.ts', '.jsx', '.js'], 21 | }, 22 | }; 23 | 24 | export default webpackconfiguration; 25 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import webpack from 'webpack'; 3 | 4 | 5 | 6 | const webpackconfiguration: webpack.Configuration = { 7 | entry: './src/index.tsx', 8 | output: { 9 | path: path.resolve(__dirname, 'dist'), 10 | publicPath: '/', 11 | filename: 'index.js', 12 | clean: true, 13 | library: { 14 | name: 'd3reacts', 15 | type: 'umd', 16 | }, 17 | globalObject: 'this' 18 | }, 19 | optimization: { 20 | minimize: true 21 | }, 22 | externals: [ 23 | { 24 | react: { 25 | root: 'React', 26 | commonjs2: 'react', 27 | commonjs: 'react', 28 | amd: 'react' 29 | }, 30 | 'react-dom': { 31 | root: 'ReactDOM', 32 | commonjs2: 'react-dom', 33 | commonjs: 'react-dom', 34 | amd: 'react-dom' 35 | } 36 | } 37 | ], 38 | mode: process.env.NODE_ENV === "production" ? "production" : "development", 39 | module: { 40 | rules: [ 41 | { 42 | test: /\.tsx?$/, 43 | loader: 'ts-loader', 44 | exclude: /node_modules/, 45 | }, 46 | { 47 | test: /.(css)$/, 48 | exclude: /node_modules/, 49 | use: ['style-loader', 'css-loader'], 50 | }, 51 | ], 52 | }, 53 | resolve: { 54 | extensions: ['.tsx', '.ts', '.js'], 55 | }, 56 | } 57 | 58 | export default webpackconfiguration; 59 | -------------------------------------------------------------------------------- /webpack.cypress.ts: -------------------------------------------------------------------------------- 1 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 2 | import path from 'path'; 3 | import webpack from 'webpack'; 4 | import { merge } from 'webpack-merge'; 5 | import webpackconfiguration from './webpack.common'; 6 | 7 | const webpackdevconfiguration: webpack.Configuration = { 8 | entry: './tests/index.cypress.tsx', 9 | output: { 10 | path: path.resolve(__dirname, 'dist'), 11 | publicPath: '/', 12 | filename: 'index.cypress.js', 13 | }, 14 | mode: 'development', 15 | plugins: [ 16 | new HtmlWebpackPlugin({ 17 | template: './public/index.html', 18 | }) 19 | ], 20 | } 21 | 22 | export default merge(webpackconfiguration, webpackdevconfiguration); -------------------------------------------------------------------------------- /webpack.dev.ts: -------------------------------------------------------------------------------- 1 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 2 | import path from 'path'; 3 | import webpack from 'webpack'; 4 | import { merge } from 'webpack-merge'; 5 | import webpackconfiguration from './webpack.common'; 6 | 7 | const webpackdevconfiguration: webpack.Configuration = { 8 | entry: './src/index.dev.tsx', 9 | output: { 10 | path: path.resolve(__dirname, 'dist'), 11 | publicPath: '/', 12 | filename: 'index.dev.js', 13 | }, 14 | mode: 'development', 15 | plugins: [ 16 | new HtmlWebpackPlugin({ 17 | template: './public/index.html', 18 | }) 19 | ], 20 | } 21 | 22 | export default merge(webpackconfiguration, webpackdevconfiguration); -------------------------------------------------------------------------------- /webpack.prod.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import webpack from 'webpack'; 3 | import { merge } from 'webpack-merge'; 4 | import webpackconfiguration from './webpack.common'; 5 | 6 | const webpackprodconfiguration: webpack.Configuration = { 7 | entry: './src/index.tsx', 8 | output: { 9 | path: path.resolve(__dirname, 'dist'), 10 | publicPath: '/', 11 | filename: 'index.js', 12 | clean: true, 13 | library: { 14 | name: 'd3reactor', 15 | type: 'umd', 16 | }, 17 | globalObject: 'this' 18 | }, 19 | mode: 'production', 20 | externals: [ 21 | { 22 | react: { 23 | root: 'React', 24 | commonjs2: 'react', 25 | commonjs: 'react', 26 | amd: 'react' 27 | }, 28 | 'react-dom': { 29 | root: 'ReactDOM', 30 | commonjs2: 'react-dom', 31 | commonjs: 'react-dom', 32 | amd: 'react-dom' 33 | } 34 | } 35 | ], 36 | } 37 | 38 | export default merge(webpackconfiguration, webpackprodconfiguration); 39 | --------------------------------------------------------------------------------