├── .github └── workflows │ └── workflow.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── .prettierignore ├── .scripts ├── get_gh_pages_url.js └── publish_storybook.sh ├── .storybook ├── main.mjs ├── manager.js ├── preview-head.html └── preview.mjs ├── .vscode └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── cypress.config.ts ├── cypress ├── fixtures │ └── example.json ├── plugins │ └── index.js └── support │ ├── commands.js │ ├── commands.ts │ ├── component-index.html │ ├── component.ts │ ├── e2e.ts │ └── index.js ├── jest.config.js ├── package.json ├── pnpm-lock.yaml ├── prettier.config.js ├── setupVitest.js ├── slim.config.js ├── speedo.gif ├── src ├── __tests__ │ ├── ForceRender.spec.jsx │ ├── ReactSpeedometer.spec.jsx │ ├── ReactSpeedometer.test.jsx │ └── utils.test.jsx ├── core │ ├── config │ │ ├── configure.js │ │ └── index.js │ ├── enums.js │ ├── render │ │ └── index.js │ ├── theme │ │ └── index.js │ ├── types │ │ └── index.d.ts │ └── util │ │ ├── get-needle-transition.js │ │ ├── index.js │ │ └── index.test.js ├── index.d.ts ├── index.jsx └── stories │ ├── ReactSpeedometer.stories.jsx │ ├── auto-refresh.jsx │ ├── multi-speedometers.jsx │ └── speedo-button.jsx ├── vite.config.js └── vitest.config.js /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: Test Coverage 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | env: 10 | CYPRESS_CACHE_FOLDER: /home/runner/.cache/Cypress 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [20.x] 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Set pnpm as package manager 25 | uses: pnpm/action-setup@v4 26 | 27 | - name: Set up Node.js ${{ matrix.node-version }} 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | cache: 'pnpm' 32 | 33 | - name: Get pnpm store directory 34 | shell: bash 35 | run: | 36 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 37 | 38 | - name: Setup pnpm cache 39 | uses: actions/cache@v4 40 | with: 41 | path: | 42 | ${{ env.STORE_PATH }} 43 | node_modules 44 | /home/runner/.cache/Cypress 45 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }} 46 | restore-keys: | 47 | ${{ runner.os }}-pnpm-store- 48 | 49 | - name: Install dependencies 50 | run: pnpm install --frozen-lockfile 51 | 52 | - name: Cypress install 53 | run: pnpm cypress install 54 | 55 | # ref: https://github.com/cypress-io/github-action 56 | - name: Setup cypress 57 | uses: cypress-io/github-action@v6 58 | with: 59 | install: false 60 | runTests: false 61 | browser: chrome 62 | headless: true 63 | # cache-key: cypress-${{ runner.os }}-cypress-${{ hashFiles('pnpm-lock.yaml') }} 64 | cache-key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }} 65 | 66 | - name: Run the tests and generate coverage report 67 | run: pnpm run full-test 68 | 69 | - name: Upload coverage to Codecov 70 | uses: codecov/codecov-action@v2 71 | 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .idea 4 | dist 5 | *.sublime-* 6 | .DS_Store 7 | *.code-workspace 8 | # jest coverage folder 9 | coverage 10 | 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | lib 2 | src 3 | stories 4 | .storybook 5 | 6 | .scripts 7 | 8 | .git 9 | .gitmodules 10 | 11 | .babelrc 12 | *.gif 13 | *.code-workspace 14 | .DS_Store 15 | 16 | *.config.js 17 | 18 | .prettierignore 19 | .prettierrc 20 | 21 | .travis.yml 22 | 23 | yarn.lock 24 | yarn-error.log 25 | 26 | .nvmrc 27 | .eslintrc 28 | 29 | .vscode/ 30 | 31 | *.md 32 | 33 | # coverage files 34 | coverage 35 | 36 | CHANGELOG 37 | .github 38 | .circleci 39 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /package.json 2 | -------------------------------------------------------------------------------- /.scripts/get_gh_pages_url.js: -------------------------------------------------------------------------------- 1 | // IMPORTANT 2 | // --------- 3 | // This is an auto generated file with React CDK. 4 | // Do not modify this file. 5 | 6 | const parse = require('git-url-parse'); 7 | var ghUrl = process.argv[2]; 8 | const parsedUrl = parse(ghUrl); 9 | 10 | const ghPagesUrl = 'https://' + parsedUrl.owner + '.github.io/' + parsedUrl.name; 11 | console.log(ghPagesUrl); 12 | -------------------------------------------------------------------------------- /.scripts/publish_storybook.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # IMPORTANT 4 | # --------- 5 | # This is an auto generated file with React CDK. 6 | # Do not modify this file. 7 | 8 | set -e # exit with nonzero exit code if anything fails 9 | 10 | # get GIT url 11 | 12 | GIT_URL=$(git config --get remote.origin.url) 13 | if [[ $GIT_URL == "" ]]; then 14 | echo "This project is not configured with a remote git repo". 15 | exit 1 16 | fi 17 | 18 | # clear and re-create the out directory 19 | rm -rf .out || exit 0; 20 | mkdir .out; 21 | 22 | # run our compile script, discussed above 23 | # build-storybook -o .out 24 | pnpm run build-storybook 25 | 26 | # go to the out directory and create a *new* Git repo 27 | cd .out 28 | git init 29 | 30 | # inside this git repo we'll pretend to be a new user 31 | git config user.name "palerdot GH Pages Bot" 32 | git config user.email "ghpages-bot@palerdot.in" 33 | 34 | # The first and only commit to this new Git repo contains all the 35 | # files present with the commit message "Deploy to GitHub Pages". 36 | git add . 37 | git commit -m "Deploy Storybook to GitHub Pages" 38 | 39 | # Force push from the current repo's master branch to the remote 40 | # repo's gh-pages branch. (All previous history on the gh-pages branch 41 | # will be lost, since we are overwriting it.) We redirect any output to 42 | # /dev/null to hide any sensitive credential data that might otherwise be exposed. 43 | git push --force --quiet $GIT_URL master:gh-pages > /dev/null 2>&1 44 | cd .. 45 | rm -rf .out 46 | 47 | echo "" 48 | echo "=> Storybook deployed to: $(node .scripts/get_gh_pages_url.js $GIT_URL)" 49 | -------------------------------------------------------------------------------- /.storybook/main.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | features: {}, 3 | 4 | // async viteFinal(config, { configType }) { 5 | // // customize the Vite config here 6 | // // https://github.com/storybookjs/builder-vite/issues/237#issuecomment-1047819614 7 | // const { dirname } = require('path') 8 | // // https://github.com/eirslett/storybook-builder-vite/issues/55 9 | // config.root = dirname(require.resolve('@storybook/builder-vite')) 10 | 11 | // return mergeConfig(config, { 12 | // define: { 13 | // ...config.define, 14 | // global: 'window', 15 | // }, 16 | // }) 17 | // }, 18 | 19 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 20 | 21 | addons: [ 22 | // '@storybook/addon-links', 23 | '@storybook/addon-essentials', 24 | // '@storybook/addon-interactions', 25 | ], 26 | 27 | framework: { 28 | name: '@storybook/react-vite', 29 | options: {}, 30 | }, 31 | 32 | core: { 33 | // ref: https://storybook.js.org/docs/react/configure/telemetry 34 | // 👈 Disables telemetry 35 | disableTelemetry: true, 36 | }, 37 | 38 | docs: {}, 39 | 40 | // typescript: { 41 | // reactDocgen: 'react-docgen-typescript', 42 | // }, 43 | } 44 | -------------------------------------------------------------------------------- /.storybook/manager.js: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/manager-api' 2 | import { themes, create } from '@storybook/theming' 3 | 4 | import speedoTheme from '../src/core/theme' 5 | 6 | const theme = create(speedoTheme) 7 | const finalTheme = { 8 | ...themes.dark, 9 | ...theme, 10 | } 11 | 12 | addons.setConfig({ 13 | showPanel: true, 14 | // theme: themes.dark, 15 | theme: finalTheme, 16 | }) 17 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /.storybook/preview.mjs: -------------------------------------------------------------------------------- 1 | export const parameters = {} 2 | export const tags = ['autodocs'] 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.enable": false 3 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## `v3.1.0` / `v3.1.1` 4 | 5 | - Public release for `React 19` 6 | 7 | ## `v3.0.0-rc.0` 8 | 9 | - `React 19 rc` support and `Next 15` support 10 | 11 | ## 2.2.1 12 | - Add `types` export to package.json (https://github.com/palerdot/react-d3-speedometer/pull/182) 13 | 14 | ## 2.2.0 15 | - `React 18` compatible `v2.0` version generally available 16 | 17 | ## 2.1.0-rc.0 (`next`, `React 18`) 18 | - `segmentValueFormatter` function prop for customizing segment values. 19 | 20 | ## 1.0.2 21 | - `svgAriaLabel` prop for accessibility. More details - https://github.com/palerdot/react-d3-speedometer/issues/135 22 | 23 | ## 1.0.1 24 | - `PropTypes` moved to `dependencies` from `peerDependencies`. ref: https://github.com/facebook/prop-types#how-to-depend-on-this-package. sandbox: https://codesandbox.io/s/kind-breeze-esuge?file=/src/App.js 25 | 26 | ## 1.0.0 (v17 React support) 27 | - `React v17` and `v6 d3` support. **No Breaking Changes.** 28 | 29 | ## 0.14.1 30 | - `Typescript` types for `valueTextFontWeight` config/prop 31 | 32 | ## 0.14.0 33 | - `valueTextFontWeight` config/prop to control font weight of current value. ref: https://codesandbox.io/s/eloquent-morning-mnysk?file=/src/App.js 34 | - *dev changes*: prettier changes: `singleQuote: true`, `arrowParens: avoid`. `prettier` upgraded to `v2` 35 | - *dev changes*: `jest v26` 36 | 37 | ## 1.0.0-rc.0 38 | - First `1.0` Release candidate with `React 17` support 39 | 40 | ## 0.13.1 41 | - ignore `coverage` folder from npm bundle 42 | 43 | ## 0.13.0 44 | - `CustomSegmentLabelPosition`, `Transition` types for both Typescript and JS. Resolves https://github.com/palerdot/react-d3-speedometer/issues/81 45 | - `100%` Test coverage 46 | - `codecov`, `github actions` integration 47 | 48 | ## 0.12.0 49 | - removed `@babel/runtime-corejs2` as dependency. ref: https://github.com/palerdot/react-d3-speedometer/issues/76 50 | 51 | ## 0.11.0 52 | - migrated to `lodash-es` from `lodash` for better tree shaking. Exporting `types` and `themes` from core for better reusablility. 53 | - removed `lodash` dependency. ref: https://codesandbox.io/s/zen-darkness-c3ev3, https://codesandbox.io/s/gracious-swanson-1rts8 54 | 55 | ## 0.10.1 56 | - `npmignore` to reduce npm tarball/package size. Linking to github image asset for README 57 | 58 | ## 0.10.0 59 | - `customSegmentLabels` prop to display custom labels. [Live Example](https://palerdot.in/react-d3-speedometer/?path=/story/reactspeedometer--custom-segment-labels) 60 | - *bugfix*. Fixed https://github.com/palerdot/react-d3-speedometer/issues/68 61 | 62 | ## 0.9.0 63 | - `Typescript` support with typescript definition file 64 | 65 | ## 0.8.0 66 | - `paddingHorizontal`, `paddingVertical` props to configure space for label texts of bigger font sizes. 67 | 68 | ## 0.7.3 69 | - `dimensionUnit` prop to configure `width/height` ... Defaults to `px`. More context - https://developer.mozilla.org/en-US/docs/Web/SVG/Content_type#Length 70 | 71 | ## 0.7.2 72 | - `labelFontSize` and `valueTextFontSize` configurable props. 73 | 74 | ## 0.7.1 75 | - `bugfix`. Handle negative values and custom min/max values for `customSegmentStops`. Fixes - https://github.com/palerdot/react-d3-speedometer/issues/51 and https://github.com/palerdot/react-d3-speedometer/issues/52. ref - https://codesandbox.io/s/jolly-thompson-2k3d7 76 | 77 | ## 0.7.0 78 | - new `customSegmentStops` configuration to customize segments, ref: https://codesandbox.io/s/purple-cdn-eu9xf. **Complete rewrite** of core, where core is modularized and separated from lifecycle methods 79 | 80 | ## 0.6.1 81 | - removed unwanted `global` package from dependency. ref: - https://codesandbox.io/s/billowing-lake-9somf 82 | 83 | ## 0.6.0 84 | - new option/prop `segmentColors` for giving custom segment colors. Reference - https://palerdot.in/react-d3-speedometer/?path=/story/react-d3-speedometer--custom-segment-colors. ref: - https://codesandbox.io/s/relaxed-silence-c3qkb 85 | 86 | ## 0.5.6 87 | - `maxSegmentLabels` now takes `0` as valid value. Addresses - https://github.com/palerdot/react-d3-speedometer/issues/43 88 | 89 | ## 0.5.5 90 | - `babel 7` runtime issue fixed. Affected versions - `0.5.0` to `0.5.4` 91 | 92 | ## 0.5.0 93 | - `maxSegmentLabels` prop to limit labels for segment. Useful for displaying gradient like effect. Example - https://palerdot.in/react-d3-speedometer/?path=/story/react-d3-speedometer--gradient-effect-with-large-number-of-segments-and-maxsegmentlabels-config 94 | - `currentValuePlaceholderStyle` to configure custom placeholder for current value. Example - https://palerdot.in/react-d3-speedometer/?path=/story/react-d3-speedometer--custom-current-value-placeholder-style-for-eg-value 95 | 96 | ## 0.4.2 97 | - `needleHeightRatio` for controlling the height of the needle. Takes a float between 0 and 1 and controls the height. 98 | 99 | ## 0.4.1 100 | - Handle `d3` ticks behaviour when we get a single tick. Refer https://github.com/d3/d3-scale/issues/149 for more info 101 | 102 | ## 0.4.0 103 | - Bumped d3 version to 5.x 104 | - Handled specific `d3-scale` behaviour (https://github.com/d3/d3-scale/issues/149). Fixes https://github.com/palerdot/react-d3-speedometer/issues/27 105 | 106 | ## 0.3.3 107 | - Handling invalid `needleTransition` prop. Switching to default `easeQuadInOut` by throwing a warning. 108 | 109 | ## 0.3.2 110 | - Using simple string replace for `currentValueText` instead of template literal for better support for IE - https://caniuse.com/#feat=template-literals 111 | 112 | ## 0.3.1 113 | - updating `peerDependencies` with `React 16` 114 | 115 | ## 0.3.0 116 | - `REACT 16` updating react version to 16, along with enzyme to version 3. 117 | 118 | ## 0.2.3 119 | - `currentValueText` configuration to display custom current value text. 120 | 121 | ## 0.2.2 122 | - `valueFormat` option for formatting the values. Should be a valid input for - https://github.com/d3/d3-format#locale_format 123 | 124 | ## 0.2.1 125 | - Tweaked the positioning of current value element to be 23 points below so that it is legible in smaller speedometers. 126 | 127 | ## 0.2.0 128 | - `forceRender` config option, to rerender the whole component on props change. Previously, only the values are updated and animated. 129 | 130 | ## 0.1.11 131 | - new test case for custom `textColor` prop 132 | 133 | ## 0.1.10 134 | - new test case for validating default color 135 | 136 | ## 0.1.9 137 | - new test case for checking number of segments 138 | 139 | ## 0.1.8 140 | - configuring `textColor` 141 | 142 | ## 0.1.7 143 | - Basic test coverage using enzyme 144 | 145 | ## 0.1.5, 0.1.6 146 | 147 | - Moving to `MIT` license 148 | 149 | ## 0.1.4 150 | 151 | - Make `needleTransitionDuration` configurable 152 | - Make `needleTransition` configurable 153 | - make `ringWidth` configurable 154 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to React D3 Speedometer Component 2 | 3 | We welcome your help to make this component better. This document will help to streamline the contributing process and save everyone's precious time. 4 | 5 | ## Development Setup 6 | 7 | This component has been setup with [React CDK](https://github.com/kadirahq/react-cdk). Refer [React CDK documentation](https://github.com/kadirahq/react-cdk)) to get started with the development. 8 | 9 | ### Style 10 | 11 | This project uses `4 spaces` for indentation on top of the `airbnb` style. 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Arun Kumar 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 | # react-d3-speedometer 2 | 3 | > React library for showing speedometer like gauge using d3. 4 | 5 | 6 | ![react-d3-speedometer](./speedo.gif) 7 | 8 | [![Codecov](https://img.shields.io/codecov/c/gh/palerdot/react-d3-speedometer)](https://codecov.io/gh/palerdot/react-d3-speedometer) 9 | [![npm](https://img.shields.io/npm/v/react-d3-speedometer/latest?style=flat-square)](https://www.npmjs.com/package/react-d3-speedometer) 10 | [![npm](https://img.shields.io/npm/dt/react-d3-speedometer.svg)](https://www.npmjs.com/package/react-d3-speedometer) 11 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) 12 | 13 | 14 | **Note**: `v3.x` is compatible with `React v19` (and `Next 15`). `v2.x` is compatible with `React v18`. `v1.x` is compatible with `React 17`. Please use latest `v0.x` (`v0.14.1` at the time of writing) if you are using `React 16`. 15 | 16 | [![NPM](https://nodei.co/npm/react-d3-speedometer.png)](https://npmjs.org/package/react-d3-speedometer) 17 | 18 | 19 | # `react-d3-speedometer` 20 | 21 | - [Getting Started](#getting-started) 22 | - [Configuration Options](#configuration-options) 23 | - [Examples](#examples) 24 | - [FAQ](#faq) 25 | - [Ports](#ports) 26 | 27 | ## Getting Started: 28 | 29 | Install with `yarn` or `npm`. 30 | 31 | **Yarn:** 32 | ``` 33 | yarn add react-d3-speedometer 34 | ``` 35 | 36 | **npm:** 37 | 38 | ``` 39 | npm install --save react-d3-speedometer 40 | ``` 41 | 42 | And, use it like 43 | 44 | ```javascript 45 | // import the component 46 | import ReactSpeedometer from "react-d3-speedometer" 47 | // and just use it 48 | 49 | ``` 50 | 51 | ### Slim Build: 52 | 53 | There is a `Slim` build available without bundling `d3`. This project uses `d3` *micro bundles*. If your project also uses `d3` *microbundles*, you can opt for **slim build**. Necessary `d3` dependencies required for slim build to work are - `d3-array`, `d3-color`, `d3-ease`, `d3-format`, `d3-interpolate`, `d3-scale`, `d3-selection`, `d3-shape`, `d3-transition`. 54 | ```javascript 55 | // sample slim build usage 56 | import ReactSpeedometer from "react-d3-speedometer/slim" 57 | // and use it 58 | 59 | ``` 60 | 61 | [Live Examples](https://palerdot.in/react-d3-speedometer) 62 | 63 | ## Configuration Options: 64 | 65 | | prop | type | default | comments | 66 | | ------------|:--------------:| --------:| ---------| 67 | | value | Number | 0 | Make sure your value is between your `minValue` and `maxValue` | 68 | | minValue | Number | 0 | | 69 | | maxValue | Number | 1000 | | 70 | | segments | Number | 5 | Number of segments in the speedometer. Please note, `segments` is calculated with [d3-ticks]() which is an approximate count that is uniformly spaced between min and max. Please refer to [d3-ticks](https://github.com/d3/d3-scale/blob/master/README.md#continuous_ticks) and [d3-array ticks](https://github.com/d3/d3-array#ticks) for more detailed info. | 71 | | maxSegmentLabels | Number | value from 'segments' prop | Limit the number of segment labels to displayed. This is useful for acheiving a gradient effect by giving arbitrary large number of `segments` and limiting the labels with this prop. [See Live Example](https://palerdot.in/react-d3-speedometer/?path=/story/react-d3-speedometer--gradient-effect-with-large-number-of-segments-and-maxsegmentlabels-config). Please note, `maxSegmentLabels` is calculated with [d3-ticks]() which is an approximate count that is uniformly spaced between min and max. Please refer to [d3-ticks](https://github.com/d3/d3-scale/blob/master/README.md#continuous_ticks) and [d3-array ticks](https://github.com/d3/d3-array#ticks) for more detailed info. | 72 | | forceRender | Boolean | false | After initial rendering/mounting, when props change, only the `value` is changed and animated to maintain smooth visualization. But, if you want to force rerender the whole component like change in segments, colors, dimensions etc, you can use this option to force rerender of the whole component on props change. | 73 | | width | Number | 300 | **diameter** of the speedometer and the **width** of the svg element | 74 | | height | Number | 300 | height of the svg element. Height of the speedometer is always half the width since it is a **semi-circle**. For fluid width, please refere to `fluidWidth` config | 75 | | dimensionUnit | String | px | Default to `px` for `width/height`. Possible values - `"em" , "ex" , "px" , "in" , "cm" , "mm" , "pt" , ,"pc"` ... Please refer to [specification](https://developer.mozilla.org/en-US/docs/Web/SVG/Content_type#Length) for more details | 76 | | fluidWidth | Boolean | false | If `true` takes the width of the parent component. See [Live Example](https://palerdot.in/react-d3-speedometer/?selectedKind=React%20d3%20Speedometer&selectedStory=Fluid%20Width%20view&full=0&down=0&left=1&panelRight=0&downPanel=kadirahq%2Fstorybook-addon-actions%2Factions-panel) for more details | 77 | | needleColor | String | steelblue | Should be a valid color code - colorname, hexadecimal name or rgb value. Should be a valid input for [d3.interpolateHsl](https://github.com/d3/d3-interpolate#interpolateHsl) | 78 | | startColor | String | #FF471A | Should be a valid color code - colorname, hexadecimal name or rgb value. Should be a valid input for [d3.interpolateHsl](https://github.com/d3/d3-interpolate#interpolateHsl) | 79 | | endColor | String | #33CC33 | Should be a valid color code - colorname, hexadecimal name or rgb value. Should be a valid input for [d3.interpolateHsl](https://github.com/d3/d3-interpolate#interpolateHsl) | 80 | | segmentColors | Array (of colors) | [] | Custom segment colors can be given with this option. Should be an array of valid color codes. If this option is given **startColor** and **endColor** options will be ignored. | 81 | | needleTransition | String (JS) / Transition (TS) | easeQuadInOut | [d3-easing-identifiers](https://github.com/d3/d3-ease) - easeLinear, easeQuadIn, easeQuadOut, easeQuadInOut, easeCubicIn, easeCubicOut, easeCubicInOut, easePolyIn, easePolyOut, easePolyInOut, easeSinIn, easeSinOut, easeSinInOut, easeExpIn, easeExpOut, easeExpInOut, easeCircleIn, easeCircleOut, easeCircleInOut, easeBounceIn, easeBounceOut, easeBounceInOut, easeBackIn, easeBackOut, easeBackInOut, easeElasticIn, easeElasticOut, easeElasticInOut, easeElastic. There is a helper Object/Type 'Transtion', which you can import like `import { Transition } from 'react-d3-speedometer'` and use it like `Transition.easeElastic`. This works for both JS and Typescript. For `type(script)` definitions, please refer [here](./src/index.d.ts). | 82 | | needleTransitionDuration | Number | 500 | Time in milliseconds. | 83 | | needleHeightRatio | Float (between 0 and 1) | 0.9 | Control the height of the needle by giving a number/float between `0` and `1`. Default height ratio is `0.9`. | 84 | | ringWidth | Number | 60 | Width of the speedometer ring. | 85 | | textColor | String | #666 | Should be a valid color code - colorname, hexadecimal name or rgb value. Used for both showing the current value and the segment values | 86 | | valueFormat | String | | should be a valid format for [d3-format](https://github.com/d3/d3-format#locale_format). By default, no formatter is used. You can use a valid d3 format identifier (for eg: `d` to convert float to integers), to format the values. **Note:** This formatter affects all the values (current value, segment values) displayed in the speedometer | 87 | | segmentValueFormatter | Function | value => value | Custom segment values formatter function. This function is applied after 'valueFormat' prop if present. | 88 | | currentValueText | String | ${value} | Should be provided a string which should have **${value}** placeholder which will be replaced with current value. By default, current value is shown (formatted with `valueFormat`). For example, if current Value is 333 if you would like to show `Current Value: 333`, you should provide a string **`Current Value: ${value}`**. See [Live Example](https://palerdot.in/react-d3-speedometer/?selectedKind=react-d3-speedometer&selectedStory=Custom%20Current%20Value%20Text&full=0&down=1&left=1&panelRight=0) | 89 | | currentValuePlaceholderStyle | String | ${value} | Should be provided a placeholder string which will be replaced with current value in `currentValueTextProp`. For example: you can use ruby like interpolation by giving following props - ``. This is also helpful if you face `no-template-curly-in-string` eslint warnings and would like to use different placeholder for current value | 90 | | customSegmentStops | Array | [] | Array of values **starting** at `min` value, and **ending** at `max` value. This configuration is useful if you would like to split the segments at custom points or have unequal segments at preferred values. If the values does not begin and end with `min` and `max` value respectively, an error will be thrown. This configuration will override `segments` prop, since total number of segments will be `length - 1` of `customSegmentProps`. For example, `[0, 50, 75, 100]` value will have three segments - `0-50`, `50-75`, `75-100`. See [Live Example](https://palerdot.in/react-d3-speedometer/?path=/story/react-d3-speedometer--custom-segment-stops) | 91 | | customSegmentLabels | Array`` | [] | Takes an array of `CustomSegmentLabel` objects. Please note, the array length should match `segments` which is automatically calculated by `d3-ticks`. For more details, please check `segment` prop section. Each object has following keys for custom rendering of labels - `text`, `fontSize`, `color`, `position: OUTSIDE/INSIDE`. For `position`, there is a helper `CustomSegmentLabelPosition` Object/Type which you can import like `import { CustomSegmentLabelPosition } from 'react-d3-speedometer'`, and use it like `CustomSegmentLabelPosition.Inside / CustomSegmentLabelPosition.Outside`. This works for both JS and Typescript. For `type(script)` definitions, please refer [here](./src/index.d.ts). | 92 | | labelFontSize | String | 14px | Font size for segment labels/legends | 93 | | valueTextFontSize | String | 16px | Font size for current value text | 94 | | valueTextFontWeight | String | bold | Font weight for current value text. Any valid font weight identifier (500, bold etc) can be used. | 95 | | paddingHorizontal | Number | 0 | Provides right/left space for the label text. Takes a number (without explicit unit, unit will be taken from dimensionUnit config which defaults to px). Helpful when using a bigger font size for label texts. | 96 | | paddingVertical | Number | 0 | Provides top/bottom space for the current value label text below the needle. Takes a number (without explicit unit, unit will be taken from dimensionUnit config which defaults to px). Helpful when using a bigger font size for label texts. | 97 | | svgAriaLabel | String | React d3 speedometer | SVG aria-label property for Accessibility purposes | 98 | 99 | ## Examples 100 | 101 | You can view [Live Examples here](https://palerdot.in/react-d3-speedometer/?path=/story/react-d3-speedometer--default-with-no-config) 102 | 103 | #### Default with no config - [Live Example](https://palerdot.in/react-d3-speedometer/?path=/story/reactspeedometer--default-with-no-config) 104 | 105 | ```javascript 106 | 107 | ``` 108 | 109 | #### With configurations - [Live Example](https://palerdot.in/react-d3-speedometer/?path=/story/reactspeedometer--configuring-values) 110 | 111 | ```javascript 112 | 120 | ``` 121 | 122 | #### Custom Segment Colors - [Live Example](https://palerdot.in/react-d3-speedometer/?path=/story/reactspeedometer--custom-segment-colors) 123 | 124 | ```javascript 125 | 138 | ``` 139 | 140 | #### Custom Segment Labels - [Live Example](https://palerdot.in/react-d3-speedometer/?path=/story/reactspeedometer--custom-segment-labels) 141 | 142 | ```javascript 143 | // 'customSegmentLabels' prop takes an array of 'CustomSegmentLabel' Object 144 | /* 145 | type CustomSegmentLabel = { 146 | text?: string 147 | position?: OUTSIDE/INSIDE 148 | fontSize?: string 149 | color?: string 150 | } 151 | */ 152 | 153 | 185 | ``` 186 | 187 | #### Custom Segment Stops - [Live Example](https://palerdot.in/react-d3-speedometer/?path=/story/reactspeedometer--custom-segment-stops) 188 | 189 | ```javascript 190 | 195 | // `segments` prop will be ignored since it will be calculated from `customSegmentStops` 196 | // In this case there will be `4` segments (0-500, 500-750, 750-900, 900-1000) 197 | /> 198 | ``` 199 | 200 | #### Fluid Width Example - [Live Example](https://palerdot.in/react-d3-speedometer/?path=/story/reactspeedometer--fluid-width-view) 201 | 202 | ```javascript 203 | // Speedometer will take the width of the parent div (500) 204 | // any width passed will be ignored 205 |
210 | 217 |
218 | ``` 219 | 220 | #### Needle Transition Example - [Live Example](https://palerdot.in/react-d3-speedometer/?path=/story/reactspeedometer--needle-transition-duration) 221 | 222 | ```javascript 223 | 229 | ``` 230 | 231 | #### Force Render component on props change - [Live Example](https://palerdot.in/react-d3-speedometer/?path=/story/reactspeedometer--force-render-the-component) 232 | 233 | ```javascript 234 | // By default, when props change, only the value prop is updated and animated. 235 | // This is to maintain smooth visualization and to ignore breaking appearance changes like segments, colors etc. 236 | // You can override this behaviour by giving forceRender: true 237 | 238 | // render a component initially 239 | 243 | // Now, if given forceRender: true, and change the appearance all together, the component will rerender completely on props change 244 | 250 | ``` 251 | 252 | #### Needle Height Configuration Example - [Live Example](https://palerdot.in/react-d3-speedometer/?path=/story/reactspeedometer--configure-needle-length-and-font-sizes) 253 | 254 | ```javascript 255 | 259 | ``` 260 | 261 | You can give a value between `0` and `1` to control the needle height. 262 | 263 | 264 | #### Gradient Like Effect - [Live Example](https://palerdot.in/react-d3-speedometer/?path=/story/reactspeedometer--gradient-effect-with-large-number-of-segments-and-max-segment-labels-config) 265 | 266 | ```javascript 267 | 272 | ``` 273 | 274 | ### FAQ: 275 | 276 | 277 | 1) How to use with [nextjs](https://nextjs.org/)? 278 | 279 | 280 | `react-d3-speedometer` uses [lodash-es](https://www.npmjs.com/package/lodash-es) dependency for better tree shaking. For [nextjs](https://nextjs.org/), please use [next-transpile-modules](https://www.npmjs.com/package/next-transpile-modules), so that ES module exports from `lodash-es` package is properly transpiled. You can also `nextjs` dynamic imports - https://nextjs.org/docs/advanced-features/dynamic-import#with-no-ssr 281 | 282 | 283 | Please refer to this issue for more details on how to make this library work with `next.js` - https://github.com/palerdot/react-d3-speedometer/issues/89 284 | 285 | 2) How to use with `React 17`? 286 | 287 | Please use latest `v1.x` (`v1.0.0` at the time of writing). `v1.x` is compatible with `React 17`. 288 | 289 | 3) How to use with `React 16`? 290 | 291 | Please use latest `v0.x` (`v0.14.x` at the time of writing). `v0.x` is compatible with `React 16`. 292 | 293 | --- 294 | 295 | ## Ports: 296 | - Vue: [vue-speedometer](https://github.com/palerdot/vue-speedometer) 297 | - Svelte: [svelte-speedometer](https://github.com/palerdot/svelte-speedometer) 298 | 299 | --- 300 | 301 | ### Todos: 302 | 303 | - [x] Test coverage (with enzyme) 304 | - [x] Convert entire code base to ES6 305 | - [x] Split core from lifecycles 306 | - [x] Typescript support 307 | 308 | --- 309 | 310 | ### Tests: 311 | 312 | `react-d3-speedometer` comes with a test suite using [enzyme](https://github.com/airbnb/enzyme). 313 | 314 | ```javascript 315 | // navigate to root folder and run 316 | npm test 317 | // or 'yarn test' if you are using yarn 318 | ``` 319 | 320 | --- 321 | 322 | #### Feature Updates: 323 | - [`v1.0.0`] `React v17` support. `d3 v6` support. [Live Example](https://codesandbox.io/s/kind-breeze-esuge?file=/src/App.js) 324 | 325 | - [`v0.14.0`] `valueTextFontWeight` config to control font weight of current value 326 | - [`v0.10.0`] Custom labels. [Live Example](https://codesandbox.io/s/vibrant-platform-cesh3) 327 | - [`v0.9.0`] `Typescript` support 328 | - [`v0.8.0`] `paddingHorizontal`, `paddingVertical` configuration to control spacing around text. [Live Example](https://codesandbox.io/s/blazing-sun-bsm0j) 329 | - [`v0.7.0`] Custom segment stops. [Live Example](https://palerdot.in/react-d3-speedometer/?path=/story/react-d3-speedometer--custom-segment-stops) 330 | - [`v0.6.0`] Custom segment colors. [Live Example](https://codesandbox.io/s/relaxed-silence-c3qkb) 331 | 332 | --- 333 | 334 | #### Changelog: 335 | 336 | [View Changelog](CHANGELOG.md) 337 | 338 | --- 339 | 340 | #### Credits: 341 | `react-d3-speedometer` was started as a react port of the following d3 snippet - [http://bl.ocks.org/msqr/3202712](http://bl.ocks.org/msqr/3202712). Component template was initially bootstrapped with [React CDK](https://github.com/storybooks/react-cdk). Also, many thanks to `react` and `d3` ecosystem contributors. 342 | 343 | --- 344 | 345 | #### Contributing: 346 | PRs are welcome. Please create a issue/bugfix/feature branch and create an issue with your branch details. Probably I will create a similar branch in the upstream repo so that PRs can be raised against that branch instead of `master`. [master-v0.x](https://github.com/palerdot/react-d3-speedometer/tree/master-v0.x) is the main branch for `React 16` compatible changes. 347 | 348 | #### Notes 349 | - `1.x` versions are compatible with React & React DOM Versions `v17.x` 350 | - `0.x` versions are compatible with React & React DOM Versions `v16.x` 351 | For every subsequent major react upgrade, `react-d3-speedometer` will be bumped to next major versions. For example `1.x` will be compatible with `React 17.x` so on and so forth ... 352 | 353 | For similar library for VueJS, please check out [vue-speedometer](https://github.com/palerdot/vue-speedometer). 354 | 355 | For similar library for Svelte, please check out [svelte-speedometer](https://github.com/palerdot/svelte-speedometer). 356 | 357 | #### License: 358 | 359 | [MIT](LICENSE) 360 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | video: false, 5 | 6 | component: { 7 | setupNodeEvents(on, config) {}, 8 | specPattern: "src/__tests__/**/*.spec.{js,jsx}", 9 | devServer: { 10 | framework: "react", 11 | bundler: "vite", 12 | }, 13 | }, 14 | 15 | e2e: { 16 | setupNodeEvents(on, config) { 17 | // implement node event listeners here 18 | }, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /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/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 | // ref: https://docs.cypress.io/guides/component-testing/framework-configuration#Vite-Based-Projects-Vue-React 16 | const path = require('path') 17 | const { startDevServer } = require('@cypress/vite-dev-server') 18 | 19 | /** 20 | * @type {Cypress.PluginConfig} 21 | */ 22 | 23 | // eslint-disable-next-line no-unused-vars 24 | module.exports = (on, config) => { 25 | on('dev-server:start', options => { 26 | return startDevServer({ 27 | options, 28 | viteConfig: { 29 | configFile: path.resolve(__dirname, '..', '..', 'vite.config.js'), 30 | }, 31 | }) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /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/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } -------------------------------------------------------------------------------- /cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/component.ts 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 | 22 | import { mount } from 'cypress/react' 23 | 24 | // Augment the Cypress namespace to include type definitions for 25 | // your custom command. 26 | // Alternatively, can be defined in cypress/support/component.d.ts 27 | // with a at the top of your spec. 28 | declare global { 29 | namespace Cypress { 30 | interface Chainable { 31 | mount: typeof mount 32 | } 33 | } 34 | } 35 | 36 | Cypress.Commands.add('mount', mount) 37 | 38 | // Example use: 39 | // cy.mount() -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts 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') -------------------------------------------------------------------------------- /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 | 22 | // ref: https://github.com/phongnd39/cypress-jest-adapter 23 | import 'cypress-jest-adapter' 24 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transformIgnorePatterns: ['/node_modules/(?!lodash-es).+\\.js$'], 3 | verbose: true, 4 | // this option will show coverage everytime 5 | // collectCoverage: true, 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-d3-speedometer", 3 | "version": "3.1.1", 4 | "description": "React library for showing speedometer like gauge using d3.", 5 | "author": { 6 | "name": "palerdot", 7 | "email": "palerdot@gmail.com" 8 | }, 9 | "files": [ 10 | "dist" 11 | ], 12 | "types": "./dist/index.d.ts", 13 | "main": "./dist/react-d3-speedometer.es.js", 14 | "module": "./dist/react-d3-speedometer.es.js", 15 | "exports": { 16 | ".": { 17 | "types": "./dist/index.d.ts", 18 | "import": "./dist/react-d3-speedometer.es.js", 19 | "require": "./dist/react-d3-speedometer.umd.js" 20 | }, 21 | "./slim": { 22 | "import": "./dist/slim/index.js" 23 | } 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/palerdot/react-d3-speedometer.git" 28 | }, 29 | "bugs": { 30 | "url": "https://github.com/palerdot/react-d3-speedometer/issues" 31 | }, 32 | "homepage": "https://github.com/palerdot/react-d3-speedometer", 33 | "keywords": [ 34 | "react", 35 | "d3", 36 | "speedometer", 37 | "gauge", 38 | "component", 39 | "odometer" 40 | ], 41 | "license": "MIT", 42 | "scripts": { 43 | "lint": "prettier -l **/src/**/*.js", 44 | "lintfix": "prettier --write **/src/**/*.js", 45 | "test": "vitest", 46 | "full-test": "vitest --coverage && npm run cy:run", 47 | "coverage": "vitest run --coverage", 48 | "storybook": "storybook dev --port 6006", 49 | "publish-storybook": "bash .scripts/publish_storybook.sh", 50 | "cy:open": "cypress open", 51 | "cy:run": "cypress run --component", 52 | "dev": "vite", 53 | "build": "npm run build:main && npm run build:slim && npm run copy:types", 54 | "build:main": "NODE_ENV=production vite build", 55 | "build:slim": "NODE_ENV=production vite build -c slim.config.js", 56 | "copy:types": "cp ./src/index.d.ts ./dist/", 57 | "preview": "vite preview", 58 | "build-storybook": "storybook build -o .out", 59 | "prepublishOnly": "npm run build" 60 | }, 61 | "devDependencies": { 62 | "@cypress/vite-dev-server": "^5.2.1", 63 | "@rollup/plugin-node-resolve": "^16.0.0", 64 | "@storybook/addon-actions": "^8.5.3", 65 | "@storybook/addon-docs": "^8.5.3", 66 | "@storybook/addon-essentials": "^8.5.3", 67 | "@storybook/addon-interactions": "^8.5.3", 68 | "@storybook/addon-knobs": "^8.0.1", 69 | "@storybook/addon-links": "^8.5.3", 70 | "@storybook/addon-storysource": "^8.5.3", 71 | "@storybook/react": "^8.5.3", 72 | "@storybook/react-vite": "^8.5.3", 73 | "@storybook/test": "^8.5.3", 74 | "@storybook/theming": "^8.5.3", 75 | "@testing-library/jest-dom": "^5.17.0", 76 | "@testing-library/react": "^16.2.0", 77 | "@vitejs/plugin-react": "^4.3.4", 78 | "@vitest/coverage-v8": "^3.0.5", 79 | "cypress": "^14.0.2", 80 | "cypress-jest-adapter": "^0.1.1", 81 | "eslint": "^5.16.0", 82 | "eslint-config-airbnb": "^17.1.1", 83 | "eslint-config-prettier": "^6.15.0", 84 | "eslint-plugin-import": "^2.31.0", 85 | "eslint-plugin-jsx-a11y": "^6.10.2", 86 | "eslint-plugin-prettier": "^3.4.1", 87 | "eslint-plugin-react": "^7.37.4", 88 | "git-url-parse": "^11.6.0", 89 | "happy-dom": "^8.9.0", 90 | "jest": "^26.6.3", 91 | "prettier": "^2.8.8", 92 | "react": "^19.0.0", 93 | "react-dom": "^19.0.0", 94 | "rollup-plugin-analyzer": "^4.0.0", 95 | "rollup-plugin-terser": "^7.0.2", 96 | "storybook": "^8.5.3", 97 | "typescript": "^5.7.3", 98 | "vite": "^6.1.0", 99 | "vitest": "^3.0.5" 100 | }, 101 | "peerDependencies": { 102 | "react": "^19.0.0", 103 | "react-dom": "^19.0.0" 104 | }, 105 | "dependencies": { 106 | "d3-array": "^3.2.4", 107 | "d3-color": "^3.1.0", 108 | "d3-ease": "^3.0.1", 109 | "d3-format": "^3.1.0", 110 | "d3-interpolate": "^3.0.1", 111 | "d3-scale": "^4.0.2", 112 | "d3-selection": "^3.0.0", 113 | "d3-shape": "^3.2.0", 114 | "d3-transition": "^3.0.1", 115 | "lodash-es": "^4.17.21", 116 | "memoize-one": "^6.0.0", 117 | "prop-types": "^15.8.1" 118 | }, 119 | "engines": { 120 | "node": ">=14.0", 121 | "npm": ">=6.0.0" 122 | }, 123 | "packageManager": "pnpm@10.2.0", 124 | "sideEffects": false 125 | } 126 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | tabWidth: 2, 4 | arrowParens: 'avoid', 5 | printWidth: 80, 6 | trailingComma: 'es5', 7 | singleQuote: true, 8 | } 9 | -------------------------------------------------------------------------------- /setupVitest.js: -------------------------------------------------------------------------------- 1 | // ref: https://github.com/testing-library/jest-dom 2 | import '@testing-library/jest-dom' 3 | 4 | // ref: https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html 5 | // In your test setup file 6 | globalThis.IS_REACT_ACT_ENVIRONMENT = true 7 | -------------------------------------------------------------------------------- /slim.config.js: -------------------------------------------------------------------------------- 1 | // IMPORTANT: Slim config by externalizing 'd3' 2 | // There are bunch of 'd3-*' modules that are dependencies for 'react-d3-speedometer' 3 | // If the project already uses them as dependencies, they can use the slim build 4 | 5 | import path from 'path' 6 | import react from '@vitejs/plugin-react' 7 | import { defineConfig } from 'vite' 8 | import { nodeResolve } from '@rollup/plugin-node-resolve' 9 | import { terser } from 'rollup-plugin-terser' 10 | import analyze from 'rollup-plugin-analyzer' 11 | 12 | const devMode = process.env.NODE_ENV === 'development' 13 | 14 | // ref: https://blog.openreplay.com/the-ultimate-guide-to-getting-started-with-the-rollup-js-javascript-bundler 15 | function terserConfig() { 16 | return terser({ 17 | ecma: 2020, 18 | 19 | mangle: { toplevel: true }, 20 | 21 | compress: { 22 | module: true, 23 | toplevel: true, 24 | unsafe_arrows: true, 25 | drop_console: !devMode, 26 | drop_debugger: !devMode, 27 | }, 28 | 29 | output: { quote_style: 1 }, 30 | }) 31 | } 32 | 33 | // ref: https://vitejs.dev/guide/build.html#library-mode 34 | module.exports = defineConfig({ 35 | plugins: [react()], 36 | build: { 37 | outDir: path.resolve(__dirname, 'dist/slim'), 38 | lib: { 39 | entry: path.resolve(__dirname, 'src/index.jsx'), 40 | name: 'ReactSpeedometer', 41 | formats: ['es'], 42 | // fileName: format => `react-d3-speedometer.${format}.js`, 43 | fileName: () => 'index.js', 44 | }, 45 | rollupOptions: { 46 | // make sure to externalize deps that shouldn't be bundled 47 | // into your library 48 | external: [ 49 | 'react', 50 | 'react-dom', 51 | 'd3-array', 52 | 'd3-color', 53 | 'd3-ease', 54 | 'd3-format', 55 | 'd3-interpolate', 56 | 'd3-scale', 57 | 'd3-selection', 58 | 'd3-shape', 59 | 'd3-transition', 60 | ], 61 | output: { 62 | // Provide global variables to use in the UMD build 63 | // for externalized deps 64 | globals: { 65 | react: 'React', 66 | }, 67 | extend: true, 68 | sourcemap: devMode ? 'inline' : false, 69 | plugins: [terserConfig()], 70 | }, 71 | 72 | // ref: https://blog.logrocket.com/does-my-bundle-look-big-in-this/ 73 | treeshake: { 74 | moduleSideEffects: false, 75 | }, 76 | // IMPORTANT: This plugins is different from output plugins 77 | plugins: [ 78 | nodeResolve(), 79 | // analyze({ 80 | // summaryOnly: true, 81 | // filterSummary: true, 82 | // }), 83 | ], 84 | }, 85 | }, 86 | test: { 87 | globals: true, 88 | include: [ 89 | '**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', 90 | '**/__tests__/**', 91 | ], 92 | }, 93 | }) 94 | -------------------------------------------------------------------------------- /speedo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palerdot/react-d3-speedometer/93c30f4db7048803520120af0c79d3df8f075a50/speedo.gif -------------------------------------------------------------------------------- /src/__tests__/ForceRender.spec.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from 'react' 2 | import { mount } from 'cypress/react' 3 | import ReactSpeedometer from '../index' 4 | 5 | function ForceRender() { 6 | const [value, setValue] = useState(333) 7 | const [segments, setSegments] = useState(5) 8 | const [forceRender, setForceRender] = useState(false) 9 | 10 | const normalUpdate = useCallback(() => { 11 | setValue(777) 12 | }, []) 13 | 14 | const forceUpdate = useCallback(() => { 15 | setValue(417) 16 | setForceRender(true) 17 | setSegments(10) 18 | }, []) 19 | 20 | return ( 21 |
22 |
{'porumai ... force rendering'}
23 |
24 | 32 | 40 |
41 | 46 |
47 | ) 48 | } 49 | 50 | describe('Force Render of React Speedometer is working fine', () => { 51 | it('updates component normally', () => { 52 | mount() 53 | 54 | // now we can use any Cypress command to interact with the component 55 | // https://on.cypress.io/api 56 | cy.contains('333') 57 | cy.get('.speedo-segment').should('have.length', 5) 58 | 59 | // click the button 60 | cy.get('button#normal-update').click() 61 | 62 | // now we should have the updated value 63 | cy.contains('777') 64 | 65 | // we did not force rendered; our segments should be the same(5) 66 | cy.get('.speedo-segment').should('have.length', 5) 67 | }) 68 | 69 | it('force renders the component with correct value', () => { 70 | mount() 71 | 72 | // now we can use any Cypress command to interact with the component 73 | // https://on.cypress.io/api 74 | cy.contains('333') 75 | cy.get('.speedo-segment').should('have.length', 5) 76 | 77 | // click the button 78 | cy.get('button#force-render').click() 79 | 80 | // now we should have the updated value 81 | cy.contains('417') 82 | 83 | // we force rendered; our segments should be 10 (from 5) 84 | cy.get('.speedo-segment').should('have.length', 10) 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /src/__tests__/ReactSpeedometer.spec.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { mount } from 'cypress/react' 3 | import ReactSpeedometer from '../index' 4 | 5 | describe('React Speedometer', () => { 6 | it('Renders the component with correct default value', () => { 7 | mount() 8 | 9 | // now we can use any Cypress command to interact with the component 10 | // https://on.cypress.io/api 11 | cy.contains('0') 12 | }) 13 | 14 | it('Renders the component with correct value prop', () => { 15 | mount() 16 | 17 | cy.contains('333') 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/__tests__/ReactSpeedometer.test.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment happy-dom 3 | */ 4 | 5 | import React from 'react' 6 | import { render, act, screen } from '@testing-library/react' 7 | import ReactSpeedometer from '../index' 8 | 9 | afterAll(async () => { 10 | // cancel async tasks to ignore svg errors 11 | await happyDOM.cancelAsync() 12 | }) 13 | 14 | describe('', () => { 15 | test('Default value is displayed correctly', () => { 16 | const { container } = render() 17 | expect(container.querySelector('text.current-value')).toHaveTextContent('0') 18 | }) 19 | 20 | test('Default prop value is shown correctly', () => { 21 | const { container } = render() 22 | expect(container.querySelector('text.current-value')).toHaveTextContent( 23 | '333' 24 | ) 25 | }) 26 | 27 | test('svg.speedometer is present', () => { 28 | const { container } = render() 29 | expect(container.querySelectorAll('svg.speedometer')).toHaveLength(1) 30 | }) 31 | 32 | // check if the default segments is 5 by counting 'speedo-segment' 33 | test('by default we should have 5 segments', () => { 34 | const { container } = render() 35 | expect(container.querySelectorAll('path.speedo-segment')).toHaveLength(5) 36 | }) 37 | 38 | // check the text color of the current value is the default (#666) 39 | test('should have the default text color for current value', () => { 40 | const { container } = render() 41 | const cssValues = container.querySelector('text.current-value').style 42 | expect(cssValues['fill']).toBe('#666') 43 | }) 44 | 45 | // should take the color given by us in 'textColor' 46 | test('should have the text color given by us => steelblue ', () => { 47 | const { container } = render() 48 | const elem = container.querySelector('text.current-value') 49 | const style = elem.style 50 | expect(style.fill).toBe('steelblue') 51 | }) 52 | 53 | // default aria-label 54 | test('Default aria-label', () => { 55 | const { container } = render() 56 | const ariaLabel = container 57 | .querySelector('svg.speedometer') 58 | .getAttribute('aria-label') 59 | expect(ariaLabel).toBe('React d3 speedometer') 60 | }) 61 | 62 | // aria-label when using svgAriaLabel 63 | test('Custom aria-label when svgAriaLabel is used', () => { 64 | const svgAriaLabel = 'My custom SVG aria label' 65 | const { container } = render( 66 | 67 | ) 68 | const ariaLabel = container 69 | .querySelector('svg.speedometer') 70 | .getAttribute('aria-label') 71 | expect(ariaLabel).toBe(svgAriaLabel) 72 | }) 73 | 74 | // should smoothly animate only the current value; not other breaking changes 75 | test('smooth update of values', () => { 76 | const value = 333 77 | const updatedValue = 470 78 | const { container, rerender } = render() 79 | expect(container.querySelector('text.current-value')).toHaveTextContent( 80 | value.toString() 81 | ) 82 | const defaultStartColor = 'rgb(255, 71, 26)' 83 | const firstSegment = container.querySelectorAll('path.speedo-segment')[0] 84 | const firstFill = firstSegment.getAttribute('fill') 85 | expect(firstFill).toEqual(defaultStartColor) 86 | 87 | // rerender the element with updated props 88 | // ref: https://testing-library.com/docs/example-update-props/ 89 | rerender() 90 | 91 | // confirm if our value is updated 92 | expect(container.querySelector('text.current-value')).toHaveTextContent( 93 | updatedValue.toString() 94 | ) 95 | // confirm our start color is intact 96 | const updatedFill = container 97 | .querySelectorAll('path.speedo-segment')[0] 98 | .getAttribute('fill') 99 | expect(updatedFill).toEqual(defaultStartColor) 100 | }) 101 | 102 | // if force render is present, it should re render the whole component 103 | test('should rerender the whole component when "forceRender: true" ', () => { 104 | const { container, rerender } = render() 105 | expect(container.querySelectorAll('path.speedo-segment')).toHaveLength(5) 106 | // change the props and give 'rerender' true 107 | // rerender the element with updated props 108 | // ref: https://testing-library.com/docs/example-update-props/ 109 | rerender() 110 | expect(container.querySelectorAll('path.speedo-segment')).toHaveLength(10) 111 | // now change the forceRender option to false 112 | rerender() 113 | // the segments should remain in 10 114 | expect(container.querySelectorAll('path.speedo-segment')).toHaveLength(10) 115 | }) 116 | 117 | // check the format of the values 118 | test('should display the format of the values correctly', () => { 119 | // checking the default value 120 | const { container, rerender } = render() 121 | expect(container.querySelector('text.current-value')).toHaveTextContent('0') 122 | // setting label format to "d" and verifying the resulting value 123 | let passed_value = 477.7, 124 | transformed_value = '478' 125 | // change the props 126 | rerender() 127 | expect(container.querySelector('text.current-value')).toHaveTextContent( 128 | transformed_value 129 | ) 130 | }) 131 | 132 | // custom value formatter 133 | test('should render with custom segmentValueFormatter correctly', () => { 134 | const segmentValueFormatter = value => `${value}%` 135 | 136 | const { container } = render( 137 | 141 | ) 142 | 143 | const textNodes = container.querySelectorAll('text.segment-value') 144 | textNodes.forEach(node => { 145 | expect(node.textContent).toEqual(segmentValueFormatter(node.__data__)) 146 | }) 147 | }) 148 | 149 | // check the custom value text 150 | test('should display custom current text value', () => { 151 | // checking the default value 152 | const { container, rerender } = render( 153 | 154 | ) 155 | expect(container.querySelector('text.current-value')).toHaveTextContent( 156 | 'Porumai: 333' 157 | ) 158 | // change props to another text 159 | rerender( 160 | 164 | ) 165 | // test current value text reflects our new props 166 | expect(container.querySelector('text.current-value')).toHaveTextContent( 167 | 'Current Value: 555' 168 | ) 169 | }) 170 | 171 | // it should not break on invalid needle transition 172 | test('should not break on invalid needle transition', () => { 173 | const { container } = render( 174 | 175 | ) 176 | expect(container.querySelectorAll('path.speedo-segment')).toHaveLength(5) 177 | }) 178 | 179 | // [d3-scale][bug]: https://github.com/d3/d3-scale/issues/149 180 | // [fix] should render segments correctly when multiple speedometers are rendered 181 | test('should correctly show the ticks when multiple speedometers are rendered', () => { 182 | const { container } = render( 183 |
184 |
185 | 186 | 187 | 188 |
189 |
190 | ) 191 | expect(container.querySelectorAll('text.segment-value')).toHaveLength(6) 192 | }) 193 | 194 | test('should correctly take current Value placeholder from passed props', () => { 195 | const current_value = 333 196 | const { container } = render( 197 |
198 | 203 |
204 | ) 205 | expect(container.querySelector('text.current-value')).toHaveTextContent( 206 | current_value.toString() 207 | ) 208 | }) 209 | 210 | test('label and value font sizes, font weight', () => { 211 | const labelFontSize = '15px' 212 | const valueTextFontSize = '23px' 213 | const valueTextFontWeight = '500' 214 | 215 | const { container } = render( 216 | 223 | ) 224 | const cssValues = container.querySelector('text.segment-value').style 225 | expect(cssValues.fontSize).toEqual(labelFontSize) 226 | 227 | const styles = container.querySelector('text.current-value').style 228 | 229 | expect(styles.fontSize).toEqual(valueTextFontSize) 230 | expect(styles.fontWeight).toEqual(valueTextFontWeight) 231 | }) 232 | }) 233 | 234 | describe('Custom Segment Colors', () => { 235 | test('custom segment colors works as expected', () => { 236 | const segmentColors = ['red', 'blue', 'green'] 237 | const { container } = render( 238 | 239 | ) 240 | 241 | const segments = container.querySelectorAll('path.speedo-segment') 242 | 243 | segmentColors.forEach((color, index) => { 244 | const segment = segments[index] 245 | expect(segment.getAttribute('fill')).toEqual(color) 246 | }) 247 | }) 248 | 249 | test('6 custom segment colors', () => { 250 | const segmentColors = [ 251 | '#e60000', 252 | '#e67300', 253 | '#e6e600', 254 | '#bcf5bc', 255 | '#228b22', 256 | '#ff6347', 257 | ] 258 | const { container } = render( 259 | 268 | ) 269 | const segments = container.querySelectorAll('path.speedo-segment') 270 | 271 | segmentColors.forEach((color, index) => { 272 | const segment = segments[index] 273 | expect(segment.getAttribute('fill')).toEqual(color) 274 | }) 275 | }) 276 | 277 | test('custom segment colors with custom segment stops ', () => { 278 | const segmentColors = ['firebrick', 'tomato', 'gold', 'limegreen'] 279 | const { container } = render( 280 | 288 | ) 289 | const segments = container.querySelectorAll('path.speedo-segment') 290 | 291 | segmentColors.forEach((color, index) => { 292 | const segment = segments[index] 293 | expect(segment.getAttribute('fill')).toEqual(color) 294 | }) 295 | }) 296 | }) 297 | 298 | describe('Custom segment labels', () => { 299 | test('custom text labels and value text are shown correctly', () => { 300 | const currentValueText = 'Happiness Level' 301 | 302 | const customSegmentLabels = [ 303 | { 304 | text: 'Very Bad', 305 | position: 'INSIDE', 306 | color: '#555', 307 | }, 308 | { 309 | text: 'Bad', 310 | position: 'INSIDE', 311 | color: '#555', 312 | }, 313 | { 314 | text: 'Ok', 315 | position: 'INSIDE', 316 | color: '#555', 317 | fontSize: '19px', 318 | }, 319 | { 320 | text: 'Good', 321 | position: 'INSIDE', 322 | color: '#555', 323 | }, 324 | { 325 | text: 'Very Good', 326 | position: 'INSIDE', 327 | color: '#555', 328 | }, 329 | ] 330 | 331 | const { container } = render( 332 | 340 | ) 341 | 342 | const textNodes = container.querySelectorAll('text.segment-value') 343 | 344 | customSegmentLabels.forEach((label, index) => { 345 | const textNode = textNodes[index] 346 | const styles = textNode.style 347 | 348 | expect(textNode.textContent).toEqual(label.text) 349 | expect(styles['fill']).toEqual(label.color) 350 | 351 | if (label.fontSize) { 352 | expect(styles.fontSize).toEqual(label.fontSize) 353 | } 354 | }) 355 | }) 356 | }) 357 | -------------------------------------------------------------------------------- /src/__tests__/utils.test.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment jsdom 3 | */ 4 | import React from 'react' 5 | import { render, act, screen } from '@testing-library/react' 6 | import ReactSpeedometer from '../index' 7 | // import validators 8 | import { 9 | calculateNeedleHeight, 10 | calculateScale, 11 | calculateTicks, 12 | calculateSegmentLabelCount, 13 | } from '../core/util' 14 | 15 | describe('util functions are working fine', () => { 16 | test('scale and ticks works properly', () => { 17 | const min = 0 18 | const max = 1000 19 | const segments = 1000 20 | const max_segment_labels = 10 21 | 22 | const { container } = render( 23 | 27 | ) 28 | 29 | const scale1 = calculateScale({ min, max, segments }) 30 | const ticks1 = calculateTicks(scale1, { min, max, segments }) 31 | 32 | const scale2 = calculateScale({ min, max, segments: max_segment_labels }) 33 | const ticks2 = calculateTicks(scale2, { 34 | min, 35 | max, 36 | segments: max_segment_labels, 37 | }) 38 | 39 | const scale3 = calculateScale({ min, max, segments: 1 }) 40 | const ticks3 = calculateTicks(scale3, { min, max, segments: 1 }) 41 | 42 | expect(ticks2.length).toBeLessThan(ticks1.length) 43 | expect(ticks3.length).toBe(2) 44 | 45 | expect(container.querySelectorAll('text.segment-value')).toHaveLength( 46 | ticks2.length 47 | ) 48 | }) 49 | 50 | test('should throw error on invalid needle height', () => { 51 | expect(() => 52 | calculateNeedleHeight({ heightRatio: 1.1, radius: 2 }) 53 | ).toThrowError() 54 | // this one should not throw and should return some value 55 | expect(() => 56 | calculateNeedleHeight({ heightRatio: 0.9, radius: 2 }) 57 | ).not.toThrowError() 58 | expect(typeof calculateNeedleHeight({ heightRatio: 0.9, radius: 2 })).toBe( 59 | 'number' 60 | ) 61 | }) 62 | 63 | test("'maxSegmentLabels' config with no labels ", () => { 64 | const min = 0 65 | const max = 1000 66 | let segments = 1000 67 | let max_segment_labels = 5 68 | let label_count = calculateSegmentLabelCount({ 69 | maxSegmentLabelCount: max_segment_labels, 70 | segmentCount: segments, 71 | }) 72 | 73 | const { container } = render( 74 | 78 | ) 79 | 80 | const scale1 = calculateScale({ min, max, segments }) 81 | const ticks1 = calculateTicks(scale1, { min, max, segments: label_count }) 82 | 83 | expect(container.querySelectorAll('text.segment-value').length).toEqual( 84 | ticks1.length 85 | ) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /src/core/config/configure.js: -------------------------------------------------------------------------------- 1 | import memoizeOne from 'memoize-one' 2 | import { range as d3Range } from 'd3-array' 3 | import { arc as d3Arc } from 'd3-shape' 4 | import { 5 | deg2rad, 6 | sumArrayTill, 7 | calculateScale, 8 | calculateTicks, 9 | calculateSegmentStops, 10 | } from '../util' 11 | 12 | // export memoized functions 13 | export const configureScale = memoizeOne(_configureScale) 14 | export const configureTicks = memoizeOne(_configureTicks) 15 | export const configureTickData = memoizeOne(_configureTickData) 16 | export const configureArc = memoizeOne(_configureArc) 17 | 18 | function _configureScale(config) { 19 | return calculateScale({ 20 | min: config.minValue, 21 | max: config.maxValue, 22 | segments: config.maxSegmentLabels, 23 | }) 24 | } 25 | 26 | function _configureTicks(config) { 27 | const scale = configureScale(config) 28 | 29 | let ticks = calculateTicks(scale, { 30 | min: config.minValue, 31 | max: config.maxValue, 32 | segments: config.maxSegmentLabels, 33 | }) 34 | 35 | if (config.customSegmentStops.length > 0 && config.maxSegmentLabels !== 0) { 36 | ticks = config.customSegmentStops 37 | } 38 | 39 | return ticks 40 | } 41 | 42 | function _configureTickData(config) { 43 | const defaultTickData = d3Range(config.majorTicks).map(d => { 44 | return 1 / config.majorTicks 45 | }) 46 | 47 | const tickData = calculateSegmentStops({ 48 | tickData: defaultTickData, 49 | customSegmentStops: config.customSegmentStops, 50 | min: config.minValue, 51 | max: config.maxValue, 52 | }) 53 | 54 | return tickData 55 | } 56 | 57 | function _configureArc(config) { 58 | const tickData = configureTickData(config) 59 | 60 | const range = config.maxAngle - config.minAngle 61 | const r = config.width / 2 62 | 63 | const arc = d3Arc() 64 | .innerRadius(r - config.ringWidth - config.ringInset) 65 | .outerRadius(r - config.ringInset) 66 | .startAngle((d, i) => { 67 | const ratio = sumArrayTill(tickData, i) 68 | return deg2rad(config.minAngle + ratio * range) 69 | }) 70 | .endAngle((d, i) => { 71 | const ratio = sumArrayTill(tickData, i + 1) 72 | return deg2rad(config.minAngle + ratio * range) 73 | }) 74 | 75 | return arc 76 | } 77 | -------------------------------------------------------------------------------- /src/core/config/index.js: -------------------------------------------------------------------------------- 1 | import { scaleQuantize as d3ScaleQuantize } from 'd3-scale' 2 | import { interpolateHsl as d3InterpolateHsl } from 'd3-interpolate' 3 | import { rgb as d3Rgb } from 'd3-color' 4 | import { format as d3Format } from 'd3-format' 5 | 6 | import { calculateSegmentLabelCount } from '../util/' 7 | import { Transition } from '../../core/enums' 8 | 9 | export const defaultSegmentValueFormatter = value => value 10 | 11 | // default props 12 | export const DEFAULT_PROPS = { 13 | value: 0, 14 | minValue: 0, 15 | maxValue: 1000, 16 | 17 | forceRender: false, 18 | 19 | width: 300, 20 | height: 300, 21 | paddingHorizontal: 0, 22 | paddingVertical: 0, 23 | 24 | fluidWidth: false, 25 | dimensionUnit: 'px', 26 | 27 | // segments to show in the speedometer 28 | segments: 5, 29 | // maximum segment label to be shown 30 | maxSegmentLabels: -1, 31 | customSegmentStops: [], 32 | 33 | // custom segment labels 34 | customSegmentLabels: [], 35 | 36 | // color strings 37 | needleColor: 'steelblue', 38 | startColor: '#FF471A', 39 | endColor: '#33CC33', 40 | // custom segment colors; by default off 41 | segmentColors: [], 42 | 43 | // needle transition type and duration 44 | // needleTransition: "easeQuadInOut", 45 | needleTransition: Transition.easeQuadInOut, 46 | needleTransitionDuration: 500, 47 | needleHeightRatio: 0.9, 48 | 49 | ringWidth: 60, 50 | 51 | // text color (for both showing current value and segment values) 52 | textColor: '#666', 53 | 54 | // label format => https://github.com/d3/d3-format 55 | // by default ""; takes valid input for d3 format 56 | valueFormat: '', 57 | // function to customize value 58 | // this is applied after d3Format(valueFormat) 59 | segmentValueFormatter: defaultSegmentValueFormatter, 60 | 61 | // value text string format; by default it just shows the value 62 | // takes es6 template string as input with a default ${value} 63 | currentValueText: '${value}', 64 | // specifies the style of the placeholder for current value 65 | // change it some other format like "#{value}" and use it in current value text as => "Current Value: #{value}" 66 | currentValuePlaceholderStyle: '${value}', 67 | 68 | // font sizes (and other styles) 69 | labelFontSize: '14px', 70 | valueTextFontSize: '16px', 71 | valueTextFontWeight: 'bold', // any valid font weight string 72 | 73 | // Accessibility related props 74 | svgAriaLabel: 'React d3 speedometer', // aria-label of speedometer 75 | } 76 | 77 | // default config 78 | const DEFAULT_CONFIG = { 79 | ringInset: 20, 80 | 81 | pointerWidth: 10, 82 | pointerTailLength: 5, 83 | 84 | minAngle: -90, 85 | maxAngle: 90, 86 | 87 | labelInset: 10, 88 | } 89 | 90 | export const getConfig = ({ PROPS, parentWidth, parentHeight }) => { 91 | const config = { 92 | // width/height config 93 | // if fluidWidth; width/height taken from the parent of the ReactSpeedometer 94 | // else if width/height given it is used; else our default 95 | width: PROPS.fluidWidth ? parentWidth : PROPS.width, 96 | height: PROPS.fluidWidth ? parentHeight : PROPS.height, 97 | 98 | // text padding horizontal/vertical 99 | paddingHorizontal: PROPS.paddingHorizontal, 100 | paddingVertical: PROPS.paddingVertical, 101 | 102 | // width/height dimension unit ... default "px" 103 | dimensionUnit: PROPS.dimensionUnit, 104 | 105 | // ring width should be 1/4 th of width 106 | ringWidth: PROPS.ringWidth, 107 | // min/max values 108 | minValue: PROPS.minValue, 109 | maxValue: PROPS.maxValue, 110 | // color of the speedometer needle 111 | needleColor: PROPS.needleColor, 112 | // segments in the speedometer 113 | majorTicks: PROPS.segments, 114 | // custom segment points 115 | customSegmentStops: PROPS.customSegmentStops, 116 | 117 | // custom segment labels 118 | customSegmentLabels: PROPS.customSegmentLabels, 119 | 120 | // max segment labels 121 | maxSegmentLabels: calculateSegmentLabelCount({ 122 | maxSegmentLabelCount: PROPS.maxSegmentLabels, 123 | segmentCount: PROPS.segments, 124 | }), 125 | segmentColors: PROPS.segmentColors, 126 | // color range for the segments 127 | arcColorFn: 128 | PROPS.segmentColors.length > 0 129 | ? d3ScaleQuantize(PROPS.segmentColors) 130 | : d3InterpolateHsl(d3Rgb(PROPS.startColor), d3Rgb(PROPS.endColor)), 131 | // needle configuration 132 | needleTransition: PROPS.needleTransition, 133 | needleTransitionDuration: PROPS.needleTransitionDuration, 134 | needleHeightRatio: PROPS.needleHeightRatio, 135 | // text color 136 | textColor: PROPS.textColor, 137 | // label format 138 | labelFormat: d3Format(PROPS.valueFormat), 139 | // custom value formatter 140 | segmentValueFormatter: PROPS.segmentValueFormatter, 141 | // value text string (template string) 142 | currentValueText: PROPS.currentValueText, 143 | // placeholder style for 'currentValue' 144 | currentValuePlaceholderStyle: PROPS.currentValuePlaceholderStyle, 145 | 146 | // font sizes (and other styles) 147 | labelFontSize: PROPS.labelFontSize, 148 | valueTextFontSize: PROPS.valueTextFontSize, 149 | valueTextFontWeight: PROPS.valueTextFontWeight, 150 | 151 | // Accessibility related props 152 | svgAriaLabel: PROPS.svgAriaLabel, // aria-label of speedometer 153 | } 154 | 155 | return Object.assign({}, DEFAULT_CONFIG, config) 156 | } 157 | 158 | export const updateConfig = (config, params) => { 159 | return { 160 | ...config, 161 | ...params, 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/core/enums.js: -------------------------------------------------------------------------------- 1 | export const CustomSegmentLabelPosition = { 2 | Inside: 'INSIDE', 3 | Outside: 'OUTSIDE', 4 | } 5 | 6 | export const Transition = { 7 | easeLinear: 'easeLinear', 8 | easeQuadIn: 'easeQuadIn', 9 | easeQuadOut: 'easeQuadOut', 10 | easeQuadInOut: 'easeQuadInOut', 11 | easeCubicIn: 'easeCubicIn', 12 | easeCubicOut: 'easeCubicOut', 13 | easeCubicInOut: 'easeCubicInOut', 14 | easePolyIn: 'easePolyIn', 15 | easePolyOut: 'easePolyOut', 16 | easePolyInOut: 'easePolyInOut', 17 | easeSinIn: 'easeSinIn', 18 | easeSinOut: 'easeSinOut', 19 | easeSinInOut: 'easeSinInOut', 20 | easeExpIn: 'easeExpIn', 21 | easeExpOut: 'easeExpOut', 22 | easeExpInOut: 'easeExpInOut', 23 | easeCircleIn: 'easeCircleIn', 24 | easeCircleOut: 'easeCircleOut', 25 | easeCircleInOut: 'easeCircleInOut', 26 | easeBounceIn: 'easeBounceIn', 27 | easeBounceOut: 'easeBounceOut', 28 | easeBounceInOut: 'easeBounceInOut', 29 | easeBackIn: 'easeBackIn', 30 | easeBackOut: 'easeBackOut', 31 | easeBackInOut: 'easeBackInOut', 32 | easeElasticIn: 'easeElasticIn', 33 | easeElasticOut: 'easeElasticOut', 34 | easeElasticInOut: 'easeElasticInOut', 35 | easeElastic: 'easeElastic', 36 | } 37 | -------------------------------------------------------------------------------- /src/core/render/index.js: -------------------------------------------------------------------------------- 1 | import { line as d3Line, curveMonotoneX as d3CurveMonotoneX } from 'd3-shape' 2 | import { select as d3Select } from 'd3-selection' 3 | import { transition } from 'd3-transition' 4 | 5 | import isEmpty from 'lodash-es/isEmpty' 6 | import isArray from 'lodash-es/isArray' 7 | 8 | import { 9 | centerTranslation, 10 | getRadius, 11 | calculateNeedleHeight, 12 | formatCurrentValueText, 13 | sumArrayTill, 14 | } from '../util' 15 | import { getNeedleTransition } from '../util/get-needle-transition' 16 | import { 17 | configureArc, 18 | configureTicks, 19 | configureTickData, 20 | configureScale, 21 | } from '../config/configure' 22 | 23 | // ref: https://stackoverflow.com/a/64596738/1410291 24 | function constructTransition({ duration, ease }) { 25 | return transition().duration(duration).ease(ease) 26 | } 27 | 28 | export const update = ({ d3_refs, newValue, config }) => { 29 | const scale = configureScale(config) 30 | const ratio = scale(newValue) 31 | const range = config.maxAngle - config.minAngle 32 | 33 | const newAngle = config.minAngle + ratio * range 34 | // update the pointer 35 | d3_refs.pointer 36 | .transition( 37 | constructTransition({ 38 | duration: config.needleTransitionDuration, 39 | ease: getNeedleTransition(config.needleTransition), 40 | }) 41 | ) 42 | .attr('transform', `rotate(${newAngle})`) 43 | 44 | d3_refs.current_value_text.text(formatCurrentValueText(newValue, config)) 45 | } 46 | 47 | export const render = ({ container, config }) => { 48 | const r = getRadius(config) 49 | const centerTx = centerTranslation( 50 | r, 51 | config.paddingHorizontal, 52 | config.paddingVertical 53 | ) 54 | 55 | const svg = _renderSVG({ container, config }) 56 | 57 | _renderArcs({ config, svg, centerTx }) 58 | _renderLabels({ config, svg, centerTx, r }) 59 | 60 | return { 61 | current_value_text: _renderCurrentValueText({ config, svg }), 62 | pointer: _renderNeedle({ config, svg, r, centerTx }), 63 | } 64 | } 65 | 66 | // helper function to render individual parts of gauge 67 | function _renderSVG({ container, config }) { 68 | // calculate width and height 69 | const width = config.width + 2 * config.paddingHorizontal 70 | const height = config.height + 2 * config.paddingVertical 71 | 72 | return ( 73 | d3Select(container) 74 | .append('svg:svg') 75 | .attr('class', 'speedometer') 76 | .attr('width', `${width}${config.dimensionUnit}`) 77 | .attr('height', `${height}${config.dimensionUnit}`) 78 | .attr('role', 'img') 79 | .attr('focusable', 'false') 80 | .attr('aria-label', config.svgAriaLabel) 81 | // use inline styles so that width/height is not overridden 82 | .style('width', `${width}${config.dimensionUnit}`) 83 | .style('height', `${height}${config.dimensionUnit}`) 84 | ) 85 | } 86 | 87 | function _renderArcs({ config, svg, centerTx }) { 88 | const tickData = configureTickData(config) 89 | const arc = configureArc(config) 90 | 91 | let arcs = svg.append('g').attr('class', 'arc').attr('transform', centerTx) 92 | 93 | arcs 94 | .selectAll('path') 95 | .data(tickData) 96 | .enter() 97 | .append('path') 98 | .attr('class', 'speedo-segment') 99 | .attr('fill', (d, i) => { 100 | // if custom segment colors is present just use it 101 | if (!isEmpty(config.segmentColors) && config.segmentColors[i]) { 102 | return config.segmentColors[i] 103 | } 104 | 105 | return config.arcColorFn(d * i) 106 | }) 107 | .attr('d', arc) 108 | } 109 | 110 | export function _renderLabels({ config, svg, centerTx, r }) { 111 | const ticks = configureTicks(config) 112 | const tickData = configureTickData(config) 113 | const scale = configureScale(config) 114 | const range = config.maxAngle - config.minAngle 115 | 116 | // assuming we have the custom segment labels here 117 | const { customSegmentLabels } = config 118 | 119 | const isCustomLabelsPresent = 120 | isArray(customSegmentLabels) && !isEmpty(customSegmentLabels) 121 | const isCustomLabelsValid = 122 | isCustomLabelsPresent && customSegmentLabels.length === tickData.length 123 | 124 | // if custom labels present and not valid 125 | if (isCustomLabelsPresent && !isCustomLabelsValid) { 126 | throw new Error( 127 | `Custom Segment Labels should be an array with length of ${tickData.length}` 128 | ) 129 | } 130 | 131 | // we have valid custom labels 132 | if (isCustomLabelsPresent && isCustomLabelsValid) { 133 | _renderCustomSegmentLabels({ 134 | config, 135 | svg, 136 | centerTx, 137 | r, 138 | ticks, 139 | tickData, 140 | scale, 141 | range, 142 | }) 143 | // do not proceed 144 | return 145 | } 146 | 147 | // normal label rendering 148 | let lg = svg.append('g').attr('class', 'label').attr('transform', centerTx) 149 | 150 | lg.selectAll('text') 151 | .data(ticks) 152 | .enter() 153 | .append('text') 154 | .attr('transform', (d, i) => { 155 | const ratio = 156 | config.customSegmentStops.length === 0 157 | ? scale(d) 158 | : sumArrayTill(tickData, i) 159 | 160 | const newAngle = config.minAngle + ratio * range 161 | 162 | return `rotate(${newAngle}) translate(0, ${config.labelInset - r})` 163 | }) 164 | // first labelFormat is applied via d3Format 165 | // then we will apply custom 'segmentValueFormatter' function 166 | // .text(config.labelFormat) 167 | .text(value => { 168 | return config.segmentValueFormatter(config.labelFormat(value)) 169 | }) 170 | // add class for text label 171 | .attr('class', 'segment-value') 172 | // styling stuffs 173 | .style('text-anchor', 'middle') 174 | .style('font-size', config.labelFontSize) 175 | .style('font-weight', 'bold') 176 | // .style("fill", "#666"); 177 | .style('fill', config.textColor) 178 | } 179 | 180 | // helper function to render 'custom segment labels' 181 | function _renderCustomSegmentLabels({ 182 | config, 183 | svg, 184 | centerTx, 185 | r, 186 | ticks, 187 | tickData, 188 | scale, 189 | range, 190 | }) { 191 | const { customSegmentStops, customSegmentLabels } = config 192 | 193 | // helper function to calculate angle 194 | function _calculateAngle(d, i) { 195 | const ratio = 196 | customSegmentStops.length === 0 ? scale(d) : sumArrayTill(tickData, i) 197 | 198 | const newAngle = config.minAngle + ratio * range 199 | 200 | return newAngle 201 | } 202 | 203 | // calculate the angles ([avg of range angles]) 204 | const newAngles = customSegmentLabels.map((label, i) => { 205 | const curr_index = i 206 | const next_index = i + 1 207 | 208 | const d1 = ticks[curr_index] 209 | const angle1 = _calculateAngle(d1, curr_index) 210 | 211 | const d2 = ticks[next_index] 212 | const angle2 = _calculateAngle(d2, next_index) 213 | 214 | return (angle2 + angle1) / 2 215 | }) 216 | 217 | const innerRadius = r - config.ringWidth - config.ringInset 218 | const outerRadius = r - config.ringInset 219 | 220 | const position = outerRadius - (outerRadius - innerRadius) / 2 221 | 222 | let lg = svg.append('g').attr('class', 'label').attr('transform', centerTx) 223 | 224 | lg.selectAll('text') 225 | .data(customSegmentLabels) 226 | .enter() 227 | .append('text') 228 | .attr('transform', (d, i) => { 229 | const newAngle = newAngles[i] 230 | 231 | const outerText = `rotate(${newAngle}) translate(0, ${ 232 | config.labelInset - r 233 | })` 234 | const innerText = `rotate(${newAngle}) translate(0, ${ 235 | config.labelInset / 2 - position 236 | })` 237 | 238 | // by default we will show "INSIDE" 239 | return d.position === 'OUTSIDE' ? outerText : innerText 240 | }) 241 | .text(d => d.text || '') 242 | // add class for text label 243 | .attr('class', 'segment-value') 244 | // styling stuffs 245 | .style('text-anchor', 'middle') 246 | .style('font-size', d => d.fontSize || config.labelFontSize) 247 | .style('font-weight', 'bold') 248 | .style('fill', d => d.color || config.textColor) 249 | 250 | // depending on INSIDE/OUTSIDE config calculate the position/rotate/translate 251 | 252 | // utilise the color/fontSize configs 253 | } 254 | 255 | function _renderCurrentValueText({ config, svg }) { 256 | const translateX = (config.width + 2 * config.paddingHorizontal) / 2 257 | // move the current value text down depending on padding vertical 258 | const translateY = (config.width + 4 * config.paddingVertical) / 2 259 | 260 | return ( 261 | svg 262 | .append('g') 263 | .attr('transform', `translate(${translateX}, ${translateY})`) 264 | .append('text') 265 | // add class for the text 266 | .attr('class', 'current-value') 267 | .attr('text-anchor', 'middle') 268 | // position the text 23pt below 269 | .attr('y', 23) 270 | // add text 271 | .text(config.currentValue) 272 | .style('font-size', config.valueTextFontSize) 273 | .style('font-weight', config.valueTextFontWeight) 274 | .style('fill', config.textColor) 275 | ) 276 | } 277 | 278 | function _renderNeedle({ config, svg, r, centerTx }) { 279 | const needleLength = calculateNeedleHeight({ 280 | heightRatio: config.needleHeightRatio, 281 | radius: r, 282 | }) 283 | 284 | const lineData = [ 285 | [config.pointerWidth / 2, 0], 286 | [0, -needleLength], 287 | [-(config.pointerWidth / 2), 0], 288 | [0, config.pointerTailLength], 289 | [config.pointerWidth / 2, 0], 290 | ] 291 | 292 | const pointerLine = d3Line().curve(d3CurveMonotoneX) 293 | 294 | let pg = svg 295 | .append('g') 296 | .data([lineData]) 297 | .attr('class', 'pointer') 298 | .attr('transform', centerTx) 299 | .style('fill', config.needleColor) 300 | 301 | return pg 302 | .append('path') 303 | .attr('d', pointerLine) 304 | .attr('transform', `rotate(${config.minAngle})`) 305 | } 306 | -------------------------------------------------------------------------------- /src/core/theme/index.js: -------------------------------------------------------------------------------- 1 | const theme = { 2 | base: 'dark', 3 | 4 | appBg: '#413c69', 5 | appContentBg: '#2a2744', 6 | barBg: '#373359', 7 | 8 | brandTitle: 'react-d3-speedometer', 9 | brandUrl: 'https://github.com/palerdot/react-d3-speedometer', 10 | // brandTarget: '_self', 11 | } 12 | 13 | export default theme 14 | -------------------------------------------------------------------------------- /src/core/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Props, 3 | CustomSegmentLabel, 4 | CustomSegmentLabelPosition, 5 | } from "../../index.d.ts" 6 | 7 | export { Props, CustomSegmentLabel, CustomSegmentLabelPosition } 8 | -------------------------------------------------------------------------------- /src/core/util/get-needle-transition.js: -------------------------------------------------------------------------------- 1 | import { 2 | easeLinear as d3EaseLinear, 3 | easeQuadIn as d3EaseQuadIn, 4 | easeQuadOut as d3EaseQuadOut, 5 | easeQuadInOut as d3EaseQuadInOut, 6 | easeCubicIn as d3EaseCubicIn, 7 | easeCubicOut as d3EaseCubicOut, 8 | easeCubicInOut as d3EaseCubicInOut, 9 | easePolyIn as d3EasePolyIn, 10 | easePolyOut as d3EasePolyOut, 11 | easePolyInOut as d3EasePolyInOut, 12 | easeSinIn as d3EaseSinIn, 13 | easeSinOut as d3EaseSinOut, 14 | easeSinInOut as d3EaseSinInOut, 15 | easeExpIn as d3EaseExpIn, 16 | easeExpOut as d3EaseExpOut, 17 | easeExpInOut as d3EaseExpInOut, 18 | easeCircleIn as d3EaseCircleIn, 19 | easeCircleOut as d3EaseCircleOut, 20 | easeCircleInOut as d3EaseCircleInOut, 21 | easeBounceIn as d3EaseBounceIn, 22 | easeBounceOut as d3EaseBounceOut, 23 | easeBounceInOut as d3EaseBounceInOut, 24 | easeBackIn as d3EaseBackIn, 25 | easeBackOut as d3EaseBackOut, 26 | easeBackInOut as d3EaseBackInOut, 27 | easeElasticIn as d3EaseElasticIn, 28 | easeElasticOut as d3EaseElasticOut, 29 | easeElasticInOut as d3EaseElasticInOut, 30 | easeElastic as d3EaseElastic, 31 | } from 'd3-ease' 32 | import { Transition } from '../enums' 33 | 34 | // takes a 'transition string' and returns a d3 transition method 35 | // default is easeLinear 36 | export function getNeedleTransition(transition) { 37 | switch (transition) { 38 | // easeLinear 39 | case Transition.easeLinear: 40 | return d3EaseLinear 41 | // easeQuadIn as d3EaseQuadIn, 42 | case Transition.easeQuadIn: 43 | return d3EaseQuadIn 44 | // easeQuadOut as d3EaseQuadOut 45 | case Transition.easeQuadOut: 46 | return d3EaseQuadOut 47 | // easeQuadInOut as d3EaseQuadInOut 48 | case Transition.easeQuadInOut: 49 | return d3EaseQuadInOut 50 | // easeCubicIn as d3EaseCubicIn 51 | case Transition.easeCubicIn: 52 | return d3EaseCubicIn 53 | // easeCubicOut as d3EaseCubicOut, 54 | case Transition.easeCubicOut: 55 | return d3EaseCubicOut 56 | // easeCubicInOut as d3EaseCubicInOut, 57 | case Transition.easeCubicInOut: 58 | return d3EaseCubicInOut 59 | // easePolyIn as d3EasePolyIn, 60 | case Transition.easePolyIn: 61 | return d3EasePolyIn 62 | // easePolyOut as d3EasePolyOut, 63 | case Transition.easePolyOut: 64 | return d3EasePolyOut 65 | // easePolyInOut as d3EasePolyInOut, 66 | case Transition.easePolyInOut: 67 | return d3EasePolyInOut 68 | // easeSinIn as d3EaseSinIn, 69 | case Transition.easeSinIn: 70 | return d3EaseSinIn 71 | // easeSinOut as d3EaseSinOut, 72 | case Transition.easeSinOut: 73 | return d3EaseSinOut 74 | // easeSinInOut as d3EaseSinInOut, 75 | case Transition.easeSinInOut: 76 | return d3EaseSinInOut 77 | // easeExpIn as d3EaseExpIn, 78 | case Transition.easeExpIn: 79 | return d3EaseExpIn 80 | // easeExpOut as d3EaseExpOut, 81 | case Transition.easeExpOut: 82 | return d3EaseExpOut 83 | // easeExpInOut as d3EaseExpInOut, 84 | case Transition.easeExpInOut: 85 | return d3EaseExpInOut 86 | // easeCircleIn as d3EaseCircleIn, 87 | case Transition.easeCircleIn: 88 | return d3EaseCircleIn 89 | // easeCircleOut as d3EaseCircleOut, 90 | case Transition.easeCircleOut: 91 | return d3EaseCircleOut 92 | // easeCircleInOut as d3EaseCircleInOut, 93 | case Transition.easeCircleInOut: 94 | return d3EaseCircleInOut 95 | // easeBounceIn as d3EaseBounceIn, 96 | case Transition.easeBounceIn: 97 | return d3EaseBounceIn 98 | // easeBounceOut as d3EaseBounceOut, 99 | case Transition.easeBounceOut: 100 | return d3EaseBounceOut 101 | // easeBounceInOut as d3EaseBounceInOut, 102 | case Transition.easeBounceInOut: 103 | return d3EaseBounceInOut 104 | // easeBackIn as d3EaseBackIn, 105 | case Transition.easeBackIn: 106 | return d3EaseBackIn 107 | // easeBackOut as d3EaseBackOut, 108 | case Transition.easeBackOut: 109 | return d3EaseBackOut 110 | // easeBackInOut as d3EaseBackInOut, 111 | case Transition.easeBackInOut: 112 | return d3EaseBackInOut 113 | // easeElasticIn as d3EaseElasticIn, 114 | case Transition.easeElasticIn: 115 | return d3EaseElasticIn 116 | // easeElasticOut as d3EaseElasticOut, 117 | case Transition.easeElasticOut: 118 | return d3EaseElasticOut 119 | // easeElasticInOut as d3EaseElasticInOut, 120 | case Transition.easeElasticInOut: 121 | return d3EaseElasticInOut 122 | // easeElastic as d3EaseElastic, 123 | case Transition.easeElastic: 124 | return d3EaseElastic 125 | 126 | // if not a valid transition; throw a warning and return the default transition 127 | default: 128 | console.warn( 129 | `Warning: Invalid needle transition '${transition}'. Switching to default transition 'easeQuadInOut'` 130 | ) 131 | return d3EaseQuadInOut 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/core/util/index.js: -------------------------------------------------------------------------------- 1 | import _isNumber from 'lodash-es/isNumber' 2 | import _sum from 'lodash-es/sum' 3 | import isEmpty from 'lodash-es/isEmpty' 4 | import isArray from 'lodash-es/isArray' 5 | import _head from 'lodash-es/head' 6 | import _last from 'lodash-es/last' 7 | import _drop from 'lodash-es/drop' 8 | import _times from 'lodash-es/times' 9 | import _take from 'lodash-es/take' 10 | 11 | import { scaleLinear as d3ScaleLinear } from 'd3-scale' 12 | 13 | // helper function to calculate array sum till specified index 14 | export function sumArrayTill(array, index) { 15 | return _sum(_take(array, index)) 16 | } 17 | 18 | // helper function to calculate segment stops 19 | // if custom segment stops is given does the following validation 20 | // first elem === min 21 | // last elem === max 22 | // if valid, massages custom segment stops into valid tick data 23 | // if custom segment stop is not given 24 | export function calculateSegmentStops({ 25 | tickData, 26 | customSegmentStops, 27 | min, 28 | max, 29 | }) { 30 | if (!isArray(customSegmentStops) || isEmpty(customSegmentStops)) { 31 | // return existing tick data 32 | return tickData 33 | } 34 | // there is some custom segment stop 35 | // let us do the validation 36 | 37 | // first element should be equivalent to min 38 | if (_head(customSegmentStops) !== min) { 39 | throw new Error( 40 | `First value should be equivalent to min value given. Current min value - ${min}` 41 | ) 42 | } 43 | 44 | // last element shuold be equivalent to max 45 | if (_last(customSegmentStops) !== max) { 46 | throw new Error( 47 | `Last value should be equivalent to max value given. Current min value - ${max}` 48 | ) 49 | } 50 | 51 | // looks like we have a valid custom segment stop, let us massage the data 52 | // construct the relative difference values 53 | const relative_difference = customSegmentStops.map((current_stop, index) => { 54 | if (index === 0) { 55 | // ignore 56 | return 57 | } 58 | return (current_stop - customSegmentStops[index - 1]) / (max - min) 59 | }) 60 | 61 | return _drop(relative_difference) 62 | } 63 | 64 | // export validators 65 | export function calculateNeedleHeight({ heightRatio, radius }) { 66 | if (heightRatio < 0 || heightRatio > 1) { 67 | throw new Error(`Invalid needleHeightRatio given - ${heightRatio}`) 68 | } 69 | return Math.round(radius * heightRatio) 70 | } 71 | 72 | export function calculateSegmentLabelCount({ 73 | maxSegmentLabelCount, 74 | segmentCount, 75 | }) { 76 | const max_segment_label_count = parseInt(maxSegmentLabelCount, 10) 77 | const segments_count = parseInt(segmentCount, 10) 78 | 79 | return _isNumber(max_segment_label_count) && 80 | max_segment_label_count >= 0 && 81 | max_segment_label_count <= segments_count 82 | ? max_segment_label_count 83 | : segments_count 84 | } 85 | 86 | // calculate d3 scale 87 | export function calculateScale({ min, max, segments }) { 88 | return d3ScaleLinear().range([0, 1]).domain([min, max]) 89 | } 90 | 91 | // calculate ticks 92 | export function calculateTicks(scale, { min, max, segments }) { 93 | let ticks = [] 94 | ticks = scale.ticks(segments) 95 | // [d3-scale][issue]: https://github.com/d3/d3-scale/issues/149 96 | 97 | const normalize_ticks = 98 | (_last(ticks) !== max || segments < ticks.length) && ticks.length > 1 99 | 100 | if (normalize_ticks) { 101 | // let us split it ourselves 102 | const diff = (max - min) / segments 103 | ticks = [min] 104 | _times(segments, i => { 105 | ticks.push(min + (i + 1) * diff) 106 | }) 107 | } 108 | 109 | if (ticks.length === 1) { 110 | // we have this specific `d3 ticks` behaviour stepping in a specific way 111 | ticks = [min, max] 112 | } 113 | 114 | return ticks 115 | } 116 | 117 | // formats current value 118 | // ref: https://stackoverflow.com/a/29771751/1410291 119 | export function formatCurrentValueText(currentValue, config) { 120 | // get the current value 121 | const value = config.labelFormat(currentValue) 122 | // get the current placeholder style 123 | const placeholderStyle = config.currentValuePlaceholderStyle 124 | 125 | // replace the placeholder style in current text 126 | return config.currentValueText.replace(placeholderStyle, value) 127 | } 128 | 129 | export function deg2rad(deg) { 130 | return (deg * Math.PI) / 180 131 | } 132 | 133 | export function centerTranslation(r, paddingHorizontal, paddingVertical) { 134 | return `translate(${r + paddingHorizontal}, ${r + paddingVertical})` 135 | } 136 | 137 | export function getRadius(config) { 138 | return config.width / 2 139 | } 140 | -------------------------------------------------------------------------------- /src/core/util/index.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | easeLinear as d3EaseLinear, 3 | easeQuadIn as d3EaseQuadIn, 4 | easeQuadOut as d3EaseQuadOut, 5 | easeQuadInOut as d3EaseQuadInOut, 6 | easeCubicIn as d3EaseCubicIn, 7 | easeCubicOut as d3EaseCubicOut, 8 | easeCubicInOut as d3EaseCubicInOut, 9 | easePolyIn as d3EasePolyIn, 10 | easePolyOut as d3EasePolyOut, 11 | easePolyInOut as d3EasePolyInOut, 12 | easeSinIn as d3EaseSinIn, 13 | easeSinOut as d3EaseSinOut, 14 | easeSinInOut as d3EaseSinInOut, 15 | easeExpIn as d3EaseExpIn, 16 | easeExpOut as d3EaseExpOut, 17 | easeExpInOut as d3EaseExpInOut, 18 | easeCircleIn as d3EaseCircleIn, 19 | easeCircleOut as d3EaseCircleOut, 20 | easeCircleInOut as d3EaseCircleInOut, 21 | easeBounceIn as d3EaseBounceIn, 22 | easeBounceOut as d3EaseBounceOut, 23 | easeBounceInOut as d3EaseBounceInOut, 24 | easeBackIn as d3EaseBackIn, 25 | easeBackOut as d3EaseBackOut, 26 | easeBackInOut as d3EaseBackInOut, 27 | easeElasticIn as d3EaseElasticIn, 28 | easeElasticOut as d3EaseElasticOut, 29 | easeElasticInOut as d3EaseElasticInOut, 30 | easeElastic as d3EaseElastic, 31 | } from 'd3-ease' 32 | 33 | import { calculateSegmentStops } from './index' 34 | import { getNeedleTransition } from './get-needle-transition' 35 | import { getConfig, DEFAULT_PROPS } from '../config/' 36 | import { _renderLabels } from '../render/' 37 | 38 | describe('calculate segment data', () => { 39 | const tickData = [0.33, 0.33, 0.33] 40 | const min = 0 41 | const max = 1000 42 | 43 | test('for empty segment stops', () => { 44 | expect( 45 | calculateSegmentStops({ 46 | tickData, 47 | customSegmentStops: [], 48 | min, 49 | max, 50 | }) 51 | ).toEqual(tickData) 52 | }) 53 | test('for invalid segment stops', () => { 54 | expect( 55 | calculateSegmentStops({ 56 | tickData, 57 | customSegmentStops: {}, 58 | min, 59 | max, 60 | }) 61 | ).toEqual(tickData) 62 | }) 63 | 64 | // test for error if min does not match 65 | test('throw error for invalid min value as first', () => { 66 | expect(() => 67 | calculateSegmentStops({ 68 | tickData, 69 | customSegmentStops: [0, 50, 1000], 70 | min: 10, 71 | max, 72 | }) 73 | ).toThrowError(/min/) 74 | }) 75 | 76 | // test for error if max does not match 77 | test('throw error for invalid max value as last', () => { 78 | expect(() => 79 | calculateSegmentStops({ 80 | tickData, 81 | customSegmentStops: [0, 50, 1000], 82 | min: 0, 83 | max: 100, 84 | }) 85 | ).toThrowError(/max/) 86 | }) 87 | 88 | // test correctly massaged data 89 | test('throw error for invalid max value as last', () => { 90 | expect( 91 | calculateSegmentStops({ 92 | tickData, 93 | customSegmentStops: [0, 500, 750, 1000], 94 | min, 95 | max, 96 | }) 97 | ).toEqual([0.5, 0.25, 0.25]) 98 | }) 99 | 100 | // test massaged data for custom min/max values 101 | test('confirm massaged data for custom min/max values', () => { 102 | expect( 103 | calculateSegmentStops({ 104 | tickData, 105 | customSegmentStops: [500, 777, 1000], 106 | min: 500, 107 | max: 1000, 108 | }) 109 | ).toEqual([0.554, 0.446]) 110 | 111 | // test for negative values 112 | expect( 113 | calculateSegmentStops({ 114 | tickData, 115 | customSegmentStops: [-120, -100, 0], 116 | min: -120, 117 | max: 0, 118 | }) 119 | ).toEqual([0.16666666666666666, 0.8333333333333334]) 120 | }) 121 | }) 122 | 123 | describe('verify needle transitions', () => { 124 | test('needle transitions', () => { 125 | expect(d3EaseLinear).toEqual(getNeedleTransition('easeLinear')) 126 | expect(d3EaseQuadIn).toEqual(getNeedleTransition('easeQuadIn')) 127 | expect(d3EaseQuadOut).toEqual(getNeedleTransition('easeQuadOut')) 128 | expect(d3EaseQuadInOut).toEqual(getNeedleTransition('easeQuadInOut')) 129 | expect(d3EaseCubicIn).toEqual(getNeedleTransition('easeCubicIn')) 130 | expect(d3EaseCubicOut).toEqual(getNeedleTransition('easeCubicOut')) 131 | expect(d3EaseCubicInOut).toEqual(getNeedleTransition('easeCubicInOut')) 132 | expect(d3EasePolyIn).toEqual(getNeedleTransition('easePolyIn')) 133 | expect(d3EasePolyOut).toEqual(getNeedleTransition('easePolyOut')) 134 | expect(d3EasePolyInOut).toEqual(getNeedleTransition('easePolyInOut')) 135 | expect(d3EaseSinIn).toEqual(getNeedleTransition('easeSinIn')) 136 | expect(d3EaseSinOut).toEqual(getNeedleTransition('easeSinOut')) 137 | expect(d3EaseSinInOut).toEqual(getNeedleTransition('easeSinInOut')) 138 | expect(d3EaseExpIn).toEqual(getNeedleTransition('easeExpIn')) 139 | expect(d3EaseExpOut).toEqual(getNeedleTransition('easeExpOut')) 140 | expect(d3EaseExpInOut).toEqual(getNeedleTransition('easeExpInOut')) 141 | expect(d3EaseCircleIn).toEqual(getNeedleTransition('easeCircleIn')) 142 | expect(d3EaseCircleOut).toEqual(getNeedleTransition('easeCircleOut')) 143 | expect(d3EaseCircleInOut).toEqual(getNeedleTransition('easeCircleInOut')) 144 | expect(d3EaseBounceIn).toEqual(getNeedleTransition('easeBounceIn')) 145 | expect(d3EaseBounceOut).toEqual(getNeedleTransition('easeBounceOut')) 146 | expect(d3EaseBounceInOut).toEqual(getNeedleTransition('easeBounceInOut')) 147 | expect(d3EaseBackIn).toEqual(getNeedleTransition('easeBackIn')) 148 | expect(d3EaseBackOut).toEqual(getNeedleTransition('easeBackOut')) 149 | expect(d3EaseBackInOut).toEqual(getNeedleTransition('easeBackInOut')) 150 | expect(d3EaseElasticIn).toEqual(getNeedleTransition('easeElasticIn')) 151 | expect(d3EaseElasticOut).toEqual(getNeedleTransition('easeElasticOut')) 152 | expect(d3EaseElasticInOut).toEqual(getNeedleTransition('easeElasticInOut')) 153 | expect(d3EaseElastic).toEqual(getNeedleTransition('easeElastic')) 154 | }) 155 | }) 156 | 157 | describe('verify configuration', () => { 158 | const expected_config = { 159 | ringInset: 20, 160 | pointerWidth: 10, 161 | pointerTailLength: 5, 162 | minAngle: -90, 163 | maxAngle: 90, 164 | labelInset: 10, 165 | width: 300, 166 | height: 300, 167 | paddingHorizontal: 0, 168 | paddingVertical: 0, 169 | dimensionUnit: 'px', 170 | ringWidth: 60, 171 | minValue: 0, 172 | maxValue: 1000, 173 | needleColor: 'steelblue', 174 | majorTicks: 5, 175 | customSegmentStops: [], 176 | customSegmentLabels: [], 177 | maxSegmentLabels: 5, 178 | segmentColors: [], 179 | needleTransition: 'easeQuadInOut', 180 | needleTransitionDuration: 500, 181 | needleHeightRatio: 0.9, 182 | textColor: '#666', 183 | currentValueText: '${value}', 184 | currentValuePlaceholderStyle: '${value}', 185 | labelFontSize: '14px', 186 | valueTextFontSize: '16px', 187 | } 188 | 189 | test('check default config', () => { 190 | const generated_config = getConfig({ 191 | PROPS: DEFAULT_PROPS, 192 | parentWidth: 500, 193 | parentHeight: 500, 194 | }) 195 | 196 | expect(generated_config).toMatchObject(expected_config) 197 | }) 198 | 199 | test('check config for fluidWidth: true', () => { 200 | const PROPS = { 201 | ...DEFAULT_PROPS, 202 | fluidWidth: true, 203 | } 204 | 205 | const expected_fluid_width_config = { 206 | ...expected_config, 207 | width: 500, 208 | height: 500, 209 | } 210 | 211 | const generated_config = getConfig({ 212 | PROPS, 213 | parentWidth: 500, 214 | parentHeight: 500, 215 | }) 216 | 217 | expect(generated_config).toMatchObject(expected_fluid_width_config) 218 | }) 219 | 220 | test("to throw error if invalid 'customSegmentLabels' config", () => { 221 | const PROPS = { 222 | ...DEFAULT_PROPS, 223 | // invalid segment label config to simulate error 224 | customSegmentLabels: ['porumai'], 225 | } 226 | 227 | const config = getConfig({ 228 | PROPS, 229 | parentWidth: 500, 230 | parentHeight: 500, 231 | }) 232 | 233 | expect(() => { 234 | _renderLabels({ config }) 235 | }).toThrow() 236 | }) 237 | }) 238 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-d3-speedometer' { 2 | enum Transition { 3 | easeLinear = 'easeLinear', 4 | easeQuadIn = 'easeQuadIn', 5 | easeQuadOut = 'easeQuadOut', 6 | easeQuadInOut = 'easeQuadInOut', 7 | easeCubicIn = 'easeCubicIn', 8 | easeCubicOut = 'easeCubicOut', 9 | easeCubicInOut = 'easeCubicInOut', 10 | easePolyIn = 'easePolyIn', 11 | easePolyOut = 'easePolyOut', 12 | easePolyInOut = 'easePolyInOut', 13 | easeSinIn = 'easeSinIn', 14 | easeSinOut = 'easeSinOut', 15 | easeSinInOut = 'easeSinInOut', 16 | easeExpIn = 'easeExpIn', 17 | easeExpOut = 'easeExpOut', 18 | easeExpInOut = 'easeExpInOut', 19 | easeCircleIn = 'easeCircleIn', 20 | easeCircleOut = 'easeCircleOut', 21 | easeCircleInOut = 'easeCircleInOut', 22 | easeBounceIn = 'easeBounceIn', 23 | easeBounceOut = 'easeBounceOut', 24 | easeBounceInOut = 'easeBounceInOut', 25 | easeBackIn = 'easeBackIn', 26 | easeBackOut = 'easeBackOut', 27 | easeBackInOut = 'easeBackInOut', 28 | easeElasticIn = 'easeElasticIn', 29 | easeElasticOut = 'easeElasticOut', 30 | easeElasticInOut = 'easeElasticInOut', 31 | easeElastic = 'easeElastic', 32 | } 33 | 34 | enum CustomSegmentLabelPosition { 35 | Outside = 'OUTSIDE', 36 | Inside = 'INSIDE', 37 | } 38 | 39 | type CustomSegmentLabel = { 40 | text?: string 41 | position?: CustomSegmentLabelPosition 42 | fontSize?: string 43 | color?: string 44 | } 45 | 46 | type Props = { 47 | value?: number 48 | 49 | minValue?: number 50 | maxValue?: number 51 | 52 | segments?: number 53 | maxSegmentLabels?: number 54 | 55 | forceRender?: boolean 56 | 57 | width?: number 58 | height?: number 59 | 60 | dimensionUnit?: string 61 | fluidWidth?: boolean 62 | 63 | needleColor?: string 64 | startColor?: string 65 | endColor?: string 66 | segmentColors?: string[] 67 | 68 | needleTransition?: Transition 69 | needleTransitionDuration?: number 70 | needleHeightRatio?: number 71 | 72 | ringWidth?: number 73 | textColor?: string 74 | 75 | valueFormat?: string 76 | segmentValueFormatter?: (value: string) => string 77 | 78 | currentValueText?: string 79 | currentValuePlaceholderStyle?: string 80 | 81 | customSegmentStops?: number[] 82 | 83 | customSegmentLabels?: CustomSegmentLabel[] 84 | 85 | labelFontSize?: string 86 | valueTextFontSize?: string 87 | valueTextFontWeight?: string 88 | 89 | paddingHorizontal?: number 90 | paddingVertical?: number 91 | 92 | // Accessibility releated props 93 | svgAriaLabel?: string 94 | } 95 | 96 | const ReactSpeedometer: React.FunctionComponent 97 | 98 | // named exports of all the types 99 | export { Props, CustomSegmentLabel, CustomSegmentLabelPosition, Transition } 100 | 101 | export default ReactSpeedometer 102 | } 103 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { format as d3Format } from 'd3-format' 4 | import { select as d3Select } from 'd3-selection' 5 | 6 | import { 7 | getConfig, 8 | DEFAULT_PROPS, 9 | updateConfig, 10 | defaultSegmentValueFormatter, 11 | } from './core/config' 12 | import { render, update } from './core/render' 13 | import { CustomSegmentLabelPosition, Transition } from './core/enums' 14 | 15 | class ReactSpeedometer extends PureComponent { 16 | static displayName = 'ReactSpeedometer' 17 | 18 | constructor(props) { 19 | super(props) 20 | 21 | // list of d3 refs to share within the components 22 | this.d3_refs = { 23 | pointer: false, 24 | current_value_text: false, 25 | } 26 | } 27 | 28 | componentDidMount() { 29 | // render the gauge here 30 | this.renderGauge() 31 | } 32 | 33 | render = () => { 34 | return
(this.gaugeDiv = ref)} /> 35 | } 36 | 37 | componentDidUpdate() { 38 | // on update, check if 'forceRender' option is present; 39 | if (this.props.forceRender) { 40 | this.renderGauge() 41 | } else { 42 | // let us just animate the value of the speedometer 43 | this.updateReadings() 44 | } 45 | } 46 | 47 | renderGauge() { 48 | this.config = getConfig({ 49 | PROPS: this.props, 50 | parentWidth: this.gaugeDiv.parentNode.clientWidth, 51 | parentHeight: this.gaugeDiv.parentNode.clientHeight, 52 | }) 53 | 54 | // remove existing gauge (if any) 55 | d3Select(this.gaugeDiv).select('svg').remove() 56 | 57 | this.d3_refs = render({ 58 | container: this.gaugeDiv, 59 | config: this.config, 60 | }) 61 | 62 | update({ 63 | d3_refs: this.d3_refs, 64 | newValue: this.props.value, 65 | config: this.config, 66 | }) 67 | } 68 | 69 | updateReadings() { 70 | this.config = updateConfig(this.config, { 71 | labelFormat: d3Format(this.props.valueFormat || ''), 72 | // consider custom value formatter if changed 73 | segmentValueFormatter: 74 | this.props.segmentValueFormatter || defaultSegmentValueFormatter, 75 | currentValueText: this.props.currentValueText || '${value}', 76 | }) 77 | 78 | // updates the readings of the gauge with the current prop value 79 | // animates between old prop value and current prop value 80 | update({ 81 | d3_refs: this.d3_refs, 82 | newValue: this.props.value || 0, 83 | config: this.config, 84 | }) 85 | } 86 | } 87 | 88 | // define the proptypes 89 | // make all the props and 'required' and provide sensible default in the 'defaultProps' 90 | ReactSpeedometer.propTypes = { 91 | value: PropTypes.number.isRequired, 92 | minValue: PropTypes.number.isRequired, 93 | maxValue: PropTypes.number.isRequired, 94 | 95 | // tracks if the component should update as the whole or just animate the value 96 | // default will just animate the value after initialization/mounting 97 | forceRender: PropTypes.bool.isRequired, 98 | 99 | width: PropTypes.number.isRequired, 100 | height: PropTypes.number.isRequired, 101 | // text padding horizontal/vertical 102 | paddingHorizontal: PropTypes.number.isRequired, 103 | paddingVertical: PropTypes.number.isRequired, 104 | 105 | dimensionUnit: PropTypes.string.isRequired, // width/height dimension ... default "px" 106 | fluidWidth: PropTypes.bool.isRequired, 107 | 108 | // segments to show in the speedometer 109 | segments: PropTypes.number.isRequired, 110 | // maximum number of labels to be shown 111 | maxSegmentLabels: PropTypes.number, 112 | // custom segment points to create unequal segments 113 | customSegmentStops: PropTypes.array, 114 | // custom segment labels that places label within the segment 115 | customSegmentLabels: PropTypes.arrayOf( 116 | PropTypes.shape({ 117 | text: PropTypes.string, 118 | position: PropTypes.oneOf(['OUTSIDE', 'INSIDE']), 119 | fontSize: PropTypes.string, 120 | color: PropTypes.string, 121 | }) 122 | ), 123 | 124 | // color strings 125 | needleColor: PropTypes.string.isRequired, 126 | startColor: PropTypes.string.isRequired, 127 | endColor: PropTypes.string.isRequired, 128 | // custom segment colors 129 | segmentColors: PropTypes.array.isRequired, 130 | 131 | // needle transition type and duration 132 | needleTransition: PropTypes.string.isRequired, 133 | needleTransitionDuration: PropTypes.number.isRequired, 134 | needleHeightRatio: PropTypes.number.isRequired, 135 | 136 | ringWidth: PropTypes.number.isRequired, 137 | textColor: PropTypes.string.isRequired, 138 | 139 | // d3 format identifier is generally a string; default "" (empty string) 140 | valueFormat: PropTypes.string.isRequired, 141 | // segment value formatter; default: value => value 142 | segmentValueFormatter: PropTypes.func, 143 | // value text format 144 | currentValueText: PropTypes.string.isRequired, 145 | // placeholder style for current value 146 | currentValuePlaceholderStyle: PropTypes.string.isRequired, 147 | 148 | // font sizes 149 | labelFontSize: PropTypes.string.isRequired, 150 | valueTextFontSize: PropTypes.string.isRequired, 151 | valueTextFontWeight: PropTypes.string.isRequired, 152 | 153 | // accessiblity props 154 | svgAriaLabel: PropTypes.string, 155 | } 156 | 157 | // define the default proptypes 158 | ReactSpeedometer.defaultProps = DEFAULT_PROPS 159 | 160 | export default ReactSpeedometer 161 | 162 | // enums 163 | export { CustomSegmentLabelPosition, Transition } 164 | -------------------------------------------------------------------------------- /src/stories/ReactSpeedometer.stories.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | // import { Button } from '@storybook/react/demo' 3 | // import { storiesOf } from '@storybook/react' 4 | 5 | // DEVELOPMENT 6 | import ReactSpeedometer from '../index' 7 | // PRODUCTION: switch to dist for checking production version 8 | // import ReactSpeedometer from "../../dist/index" 9 | 10 | import SpeedoButton from './speedo-button' 11 | import MultiSpeedoMeters from './multi-speedometers' 12 | import AutoRefresh from './auto-refresh' 13 | 14 | export default { 15 | title: '', 16 | component: ReactSpeedometer, 17 | } 18 | 19 | const textColor = '#AAA' 20 | 21 | // --------------------------------------------------- 22 | // START: Stories 23 | // --------------------------------------------------- 24 | 25 | export const DefaultWithNoConfig = () => ( 26 | 27 | ) 28 | 29 | export const ConfiguringValues = () => ( 30 | 39 | ) 40 | 41 | export const CustomSegmentLabels = () => ( 42 |
43 |
44 | 83 |
84 |
85 | 115 |
116 |
117 | ) 118 | 119 | export const CustomSegmentColors = () => ( 120 |
121 |
122 | 129 |
130 |
131 | 138 |
139 |
140 | ) 141 | 142 | export const CustomSegmentStops = () => ( 143 |
144 | 153 | 165 | 178 |
179 | ) 180 | 181 | export const FluidWidthView = () => ( 182 |
188 | 196 |
197 | ) 198 | 199 | export const NeedleTransitionDuration = () => ( 200 | 207 | ) 208 | 209 | export const ForceRenderTheComponent = () => 210 | 211 | export const ConfiguringTheFormatForValuesDisplayed = () => ( 212 | <> 213 | 221 | 228 | 229 | ) 230 | 231 | function segmentValueFormatter(value) { 232 | if (value < 200) { 233 | return `${value} 😒` 234 | } 235 | if (value < 400) { 236 | return `${value} 😐` 237 | } 238 | if (value < 600) { 239 | return `${value} 😌` 240 | } 241 | if (value < 800) { 242 | return `${value} 😊` 243 | } 244 | if (value < 900) { 245 | return `${value} 😉` 246 | } 247 | 248 | return `${value} 😇` 249 | } 250 | 251 | // custom value formatter 252 | export const CustomSegmentValueFormatter = () => ( 253 | 261 | ) 262 | 263 | export const CustomCurrentValueText = () => ( 264 | 272 | ) 273 | 274 | export const CustomCurrentValuePlaceholderStyleForEgValue = () => ( 275 | 284 | ) 285 | 286 | export const ConfigureNeedleLengthAndFontSizes = () => ( 287 | 294 | ) 295 | 296 | export const GradientEffectWithLargeNumberOfSegmentsAndMaxSegmentLabelsConfig = 297 | () => ( 298 | 305 | ) 306 | 307 | export const NoSegmentLabels = () => ( 308 |
309 |
310 | 315 |
316 | 317 |
318 | 327 |
328 |
329 | ) 330 | 331 | export const CustomizeFontSizesAndSpacing = () => ( 332 | 342 | ) 343 | 344 | // --------------------------------------------------- 345 | // END: Stories 346 | // --------------------------------------------------- 347 | -------------------------------------------------------------------------------- /src/stories/auto-refresh.jsx: -------------------------------------------------------------------------------- 1 | // sample auto refresh component to screen grab gifs 2 | import React from 'react' 3 | import ReactSpeedometer from '../../src/' 4 | 5 | class AutoRefresh extends React.Component { 6 | constructor(props) { 7 | super(props) 8 | this.state = { 9 | opacity: 1, 10 | } 11 | } 12 | componentDidMount = () => { 13 | setInterval(() => { 14 | this.setState(prevState => ({ 15 | opacity: prevState.opacity === 1 ? 0.9 : 1, 16 | })) 17 | }, 3333) 18 | } 19 | 20 | render() { 21 | return ( 22 |
28 |
36 |
41 | 53 |
54 |
59 | 67 |
68 |
73 | 90 |
91 |
92 |
99 |
100 | 140 |
141 |
142 | 172 |
173 |
174 |
175 | ) 176 | } 177 | } 178 | 179 | export default AutoRefresh 180 | -------------------------------------------------------------------------------- /src/stories/multi-speedometers.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactSpeedometer from '../index' 3 | // create multiple speedometers 4 | export default class MultiSpeedoMeters extends React.Component { 5 | constructor(props) { 6 | super(props) 7 | this.state = { 8 | speedometer1: { 9 | startColor: 'red', 10 | toggleStatus: false, 11 | value: 10, 12 | maxValue: 200, 13 | segments: 1, 14 | }, 15 | speedometer2: { 16 | startColor: 'blue', 17 | toggleStatus: false, 18 | value: 10, 19 | maxValue: 40, 20 | segments: 1, 21 | }, 22 | } 23 | 24 | this.values = [ 25 | { 26 | speedometer1: { 27 | startColor: 'red', 28 | toggleStatus: false, 29 | value: 10, 30 | maxValue: 200, 31 | segments: 1, 32 | }, 33 | speedometer2: { 34 | startColor: 'blue', 35 | toggleStatus: false, 36 | value: 10, 37 | maxValue: 40, 38 | segments: 1, 39 | }, 40 | }, 41 | { 42 | speedometer1: { 43 | startColor: 'orange', 44 | toggleStatus: false, 45 | value: 5, 46 | maxValue: 10, 47 | segments: 1, 48 | }, 49 | speedometer2: { 50 | startColor: 'green', 51 | toggleStatus: false, 52 | value: 900, 53 | maxValue: 1000, 54 | segments: 1, 55 | }, 56 | }, 57 | ] 58 | } 59 | 60 | render() { 61 | return ( 62 |
63 |

64 | Click the below button to force rerendering the whole component on 65 | props change. By default, on props change, only the speedometer 66 | value/needle value will be updated and animated for smooth 67 | visualization. Below button will toggle between two sets of totally 68 | different appearances, when forceRender option is given true. 69 |

70 | 71 | 86 | 95 | 104 |
105 | ) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/stories/speedo-button.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactSpeedometer from '../index' 3 | // a custom button with state to demonstrate force rendering 4 | export default class SpeedoButton extends React.Component { 5 | constructor(props) { 6 | super(props) 7 | this.state = { 8 | toggleStatus: false, 9 | value: 111, 10 | startColor: 'blue', 11 | segments: 5, 12 | width: 300, 13 | height: 300, 14 | } 15 | 16 | this.values = [ 17 | { 18 | value: 111, 19 | startColor: 'blue', 20 | segments: 5, 21 | width: 300, 22 | height: 300, 23 | }, 24 | { 25 | value: 222, 26 | startColor: 'orange', 27 | segments: 10, 28 | width: 400, 29 | height: 400, 30 | }, 31 | ] 32 | } 33 | 34 | render() { 35 | return ( 36 |
41 |

42 | Click the below button to force rerendering the whole component on 43 | props change. By default, on props change, only the speedometer 44 | value/needle value will be updated and animated for smooth 45 | visualization. Below button will toggle between two sets of totally 46 | different appearances, when forceRender option is given true. 47 |

48 | 49 | 64 | 73 |
74 | ) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import react from '@vitejs/plugin-react' 3 | import { defineConfig } from 'vite' 4 | import { nodeResolve } from '@rollup/plugin-node-resolve' 5 | import { terser } from 'rollup-plugin-terser' 6 | import analyze from 'rollup-plugin-analyzer' 7 | 8 | const devMode = process.env.NODE_ENV === 'development' 9 | 10 | // ref: https://blog.openreplay.com/the-ultimate-guide-to-getting-started-with-the-rollup-js-javascript-bundler 11 | function terserConfig() { 12 | return terser({ 13 | ecma: 2020, 14 | 15 | mangle: { toplevel: true }, 16 | 17 | compress: { 18 | module: true, 19 | toplevel: true, 20 | unsafe_arrows: true, 21 | drop_console: !devMode, 22 | drop_debugger: !devMode, 23 | }, 24 | 25 | output: { quote_style: 1 }, 26 | }) 27 | } 28 | 29 | // ref: https://vitejs.dev/guide/build.html#library-mode 30 | module.exports = defineConfig({ 31 | plugins: [react()], 32 | build: { 33 | lib: { 34 | entry: path.resolve(__dirname, 'src/index.jsx'), 35 | name: 'ReactSpeedometer', 36 | fileName: format => `react-d3-speedometer.${format}.js`, 37 | }, 38 | rollupOptions: { 39 | // make sure to externalize deps that shouldn't be bundled 40 | // into your library 41 | external: ['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', 'window'], 42 | output: { 43 | // Provide global variables to use in the UMD build 44 | // for externalized deps 45 | globals: { 46 | react: 'React', 47 | window: 'window', 48 | }, 49 | sourcemap: devMode ? 'inline' : false, 50 | plugins: [terserConfig()], 51 | }, 52 | // ref: https://blog.logrocket.com/does-my-bundle-look-big-in-this/ 53 | treeshake: { 54 | moduleSideEffects: false, 55 | }, 56 | // IMPORTANT: This plugins is different from output plugins 57 | plugins: [ 58 | nodeResolve(), 59 | // analyze({ 60 | // summaryOnly: true, 61 | // filterSummary: true, 62 | // }), 63 | ], 64 | }, 65 | }, 66 | test: { 67 | globals: true, 68 | setupFiles: ['./setupVitest.js'], 69 | include: [ 70 | // '**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', 71 | // '**/__tests__/*.{test}.{js,jsx}', 72 | '**/__tests__/*.test.{js,jsx}', 73 | '**/*.test.js', 74 | ], 75 | }, 76 | }) 77 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import { mergeConfig } from 'vite' 2 | import { defineConfig } from 'vitest/config' 3 | import viteConfig from './vite.config' 4 | 5 | export default mergeConfig( 6 | viteConfig, 7 | defineConfig({ 8 | test: { 9 | dangerouslyIgnoreUnhandledErrors: true, 10 | }, 11 | }) 12 | ) 13 | --------------------------------------------------------------------------------