diff --git a/README.md b/README.md index ea469dc..0ebb6d5 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ The props for the chart: |-----------------|-----------------------------|----------------------------------------------------------------|------------------------| | id | PropTypes.string.isRequired | Used for the identification of the div surrounding the chart | | | className | PropTypes.string | Add `className` to the div container | | -| style | PropTypes.object | Add `style` to the div container | { width: '100%' } | +| style | PropTypes.object | Add `style` to the div container | | | marginInPercent | PropTypes.number | Margin for the chart inside the containing SVG element | 0.05 | | cornerRadius | PropTypes.number | Corner radius for the elements in the chart | 6 | | nrOfLevels | PropTypes.number | The number of elements displayed in the arc | 3 | diff --git a/package.json b/package.json index 09c8a87..ca0e17f 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "babel-preset-react-app": "^8.0.0", "cross-env": "^5.2.1", "gh-pages": "^2.1.1", + "prettier": "^2.6.2", "react": "^17.0.1", "react-bootstrap": "^1.4.0", "react-dom": "^17.0.1", diff --git a/src/App.js b/src/App.js index d8557d6..8023f19 100644 --- a/src/App.js +++ b/src/App.js @@ -10,7 +10,7 @@ const App = () => { useEffect(() => { const timer = setTimeout(() => { setCurrentPercent(Math.random()); - setArcs([0.1, 0.5, 0.4]) + setArcs([Math.random(), Math.random(), Math.random()]) }, 3000); return () => { diff --git a/src/App.test.js b/src/App.test.js deleted file mode 100644 index a754b20..0000000 --- a/src/App.test.js +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import App from './App'; - -it('renders without crashing', () => { - const div = document.createElement('div'); - ReactDOM.render(, div); - ReactDOM.unmountComponentAtNode(div); -}); diff --git a/src/__tests__/App.test.js b/src/__tests__/App.test.js new file mode 100644 index 0000000..f905cb4 --- /dev/null +++ b/src/__tests__/App.test.js @@ -0,0 +1,9 @@ +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import App from '../App'; + +it('renders without crashing', () => { + const container = document.createElement('div'); + render(, container); + unmountComponentAtNode(container); +}); diff --git a/src/__tests__/GaugeChart.test.js b/src/__tests__/GaugeChart.test.js new file mode 100644 index 0000000..992feb5 --- /dev/null +++ b/src/__tests__/GaugeChart.test.js @@ -0,0 +1,90 @@ +import prettier from "prettier"; +import React from "react"; +import { render, unmountComponentAtNode } from "react-dom"; +import { act } from "react-dom/test-utils"; + +import GaugeChart from "../lib/GaugeChart"; + +const pretty = (html) => prettier.format(html, { parser: "html" }); + +let container = null; +beforeEach(() => { + // setup a DOM element as a render target + container = document.createElement("div"); + document.body.appendChild(container); +}); + +afterEach(() => { + // cleanup on exiting + unmountComponentAtNode(container); + container.remove(); + container = null; + + jest.clearAllMocks(); + jest.restoreAllMocks(); +}); + +it("renders", async () => { + await act(async () => { + render(, container); + }); + + expect(pretty(container.innerHTML)).toMatchInlineSnapshot(` + "
+ + + + + + + + + + + + + + + + + + + + 40% + + + + +
+ " + `); +}); + +it("should only render chart once", () => { + const utils = require("../lib/GaugeChart/utils"); + const renderChart = jest.spyOn(utils, "renderChart"); + + act(() => { + render(, container); + }); + + expect(renderChart).toHaveBeenCalledTimes(1); +}); diff --git a/src/__tests__/hooks.test.js b/src/__tests__/hooks.test.js new file mode 100644 index 0000000..480f9eb --- /dev/null +++ b/src/__tests__/hooks.test.js @@ -0,0 +1,66 @@ +import React from "react"; +import { render, unmountComponentAtNode } from "react-dom"; +import { act } from "react-dom/test-utils"; + +import { useDeepCompareEffect } from '../lib/GaugeChart/hooks' + +let container = null; +beforeEach(() => { + // setup a DOM element as a render target + container = document.createElement("div"); + document.body.appendChild(container); +}); + +afterEach(() => { + // cleanup on exiting + unmountComponentAtNode(container); + container.remove(); + container = null; +}); + +describe("useDeepCompareEffect", () => { + const Test = ({ callback, dependencies }) => { + useDeepCompareEffect(callback, dependencies); + return null; + } + + const callback = jest.fn() + + beforeEach(() => { + callback.mockReset(); + }) + + it('calls callback on initial render', () => { + act(() => { + render(, container); + }); + + expect(callback).toHaveBeenCalled(); + }); + + it('calls callback when dependencies change', () => { + act(() => { render(, container); }); + act(() => { render(, container); }); + act(() => { render(, container); }); + act(() => { render(, container); }); + act(() => { render(, container); }); + + expect(callback).toHaveBeenCalledTimes(5); + }); + + it('does not call callback when dependencies do not change', () => { + act(() => { render(, container); }); + act(() => { render(, container); }); + act(() => { render(, container); }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('does not call callback when callback function changed', () => { + act(() => { render( callback()} dependencies={['foo']} />, container); }); + act(() => { render( callback()} dependencies={['foo']} />, container); }); + act(() => { render( callback()} dependencies={['foo']} />, container); }); + + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/GaugeChart/customHooks.js b/src/lib/GaugeChart/customHooks.js deleted file mode 100644 index 0c2b9a1..0000000 --- a/src/lib/GaugeChart/customHooks.js +++ /dev/null @@ -1,22 +0,0 @@ -import _ from "lodash"; -import { useEffect, useRef } from "react"; - -const isDeepEquals = (toCompare, reference) => { - return _.isEqual(toCompare, reference); -}; - -const useDeepCompareMemo = (dependencies) => { - const ref = useRef(null); - if (isDeepEquals(dependencies, ref.current)) { - ref.current = dependencies; - } - return ref.current; -}; - -// this function compares deeply new dependencies with old one -// It works like useEffect but we are using isEqual from lodash to compares deeply -const useDeepCompareEffect = (callback, dependencies) => { - useEffect(callback, [useDeepCompareMemo(dependencies), callback]); -}; - -export default useDeepCompareEffect; diff --git a/src/lib/GaugeChart/hooks.js b/src/lib/GaugeChart/hooks.js new file mode 100644 index 0000000..4f89698 --- /dev/null +++ b/src/lib/GaugeChart/hooks.js @@ -0,0 +1,8 @@ +import { useEffect } from "react"; + +// A simple useEffect alternative for dependencies with arrays/objects by serialising to JSON string. +// As recommended by Facebook guru Dan Abramov (gaearon) for small objects: +// https://github.com/facebook/react/issues/14476#issuecomment-471199055 +export const useDeepCompareEffect = (effect, deps) => { + useEffect(effect, [JSON.stringify(deps)]); +}; diff --git a/src/lib/GaugeChart/index.js b/src/lib/GaugeChart/index.js index a5e50e8..30a5039 100644 --- a/src/lib/GaugeChart/index.js +++ b/src/lib/GaugeChart/index.js @@ -1,16 +1,10 @@ -import React, { useCallback, useEffect, useRef, useLayoutEffect } from "react"; -import { - arc, - pie, - select, - easeElastic, - scaleLinear, - interpolateHsl, - interpolateNumber, -} from "d3"; +import React, { useCallback, useRef, useLayoutEffect } from "react"; +import { select } from "d3"; import PropTypes from "prop-types"; -import useDeepCompareEffect from "./customHooks"; +import { useDeepCompareEffect } from "./hooks"; +import { renderChart } from './utils' + /* GaugeChart creates a gauge chart using D3 The chart is responsive and will have the same width as the "container" @@ -21,21 +15,6 @@ The svg element surrounding the gauge will always be square */ //Constants -const startAngle = -Math.PI / 2; //Negative x-axis -const endAngle = Math.PI / 2; //Positive x-axis - -const defaultStyle = { - width: "100%", -}; - -// Props that should cause an animation on update -const animateNeedleProps = [ - "marginInPercent", - "arcPadding", - "percent", - "nrOfLevels", - "animDelay", -]; const GaugeChart = (props) => { const svg = useRef({}); @@ -47,97 +26,37 @@ const GaugeChart = (props) => { const outerRadius = useRef({}); const margin = useRef({}); // = {top: 20, right: 50, bottom: 50, left: 50}, const container = useRef({}); - const nbArcsToDisplay = useRef(0); - const colorArray = useRef([]); - const arcChart = useRef(arc()); - const arcData = useRef([]); - const pieChart = useRef(pie()); - const prevProps = useRef(props); - let selectedRef = useRef({}); - - const initChart = useCallback( - (update, resize = false, prevProps) => { - if (update) { - renderChart( - resize, - prevProps, - width, - margin, - height, - outerRadius, - g, - doughnut, - arcChart, - needle, - pieChart, - svg, - props, - container, - arcData - ); - return; - } - - container.current.select("svg").remove(); - svg.current = container.current.append("svg"); - g.current = svg.current.append("g"); //Used for margins - doughnut.current = g.current.append("g").attr("class", "doughnut"); - - //Set up the pie generator - //Each arc should be of equal length (or should they?) - pieChart.current - .value(function (d) { - return d.value; - }) - //.padAngle(arcPadding) - .startAngle(startAngle) - .endAngle(endAngle) - .sort(null); - //Add the needle element - needle.current = g.current.append("g").attr("class", "needle"); - - renderChart( - resize, - prevProps, - width, - margin, - height, - outerRadius, - g, - doughnut, - arcChart, - needle, - pieChart, - svg, - props, - container, - arcData - ); - }, - [props] - ); + const prevProps = useRef(); + const selectedRef = useRef(); + + const initChart = useCallback(() => { + container.current = select(selectedRef.current); + container.current.select("svg").remove(); + svg.current = container.current.append("svg"); + g.current = svg.current.append("g"); //Used for margins + doughnut.current = g.current.append("g").attr("class", "doughnut"); + needle.current = g.current.append("g").attr("class", "needle"); + }, []) useLayoutEffect(() => { - setArcData(props, nbArcsToDisplay, colorArray, arcData); - container.current = select(selectedRef); - //Initialize chart initChart(); - }, [props, initChart]); + }, [initChart]); useDeepCompareEffect(() => { - if ( - props.nrOfLevels || - prevProps.current.arcsLength.every((a) => props.arcsLength.includes(a)) || - prevProps.current.colors.every((a) => props.colors.includes(a)) - ) { - setArcData(props, nbArcsToDisplay, colorArray, arcData); - } - //Initialize chart - // Always redraw the chart, but potentially do not animate it - const resize = !animateNeedleProps.some( - (key) => prevProps.current[key] !== props[key] + renderChart( + prevProps.current, + width, + margin, + height, + outerRadius, + g, + doughnut, + needle, + svg, + props, + container, ); - initChart(true, resize, prevProps.current); + prevProps.current = props; }, [ props.nrOfLevels, @@ -148,50 +67,18 @@ const GaugeChart = (props) => { props.needleBaseColor, ]); - useEffect(() => { - const handleResize = () => { - var resize = true; - - renderChart( - resize, - prevProps, - width, - margin, - height, - outerRadius, - g, - doughnut, - arcChart, - needle, - pieChart, - svg, - props, - container, - arcData - ); - }; - //Set up resize event listener to re-render the chart everytime the window is resized - window.addEventListener("resize", handleResize); - return () => { - window.removeEventListener("resize", handleResize); - }; - }, [props]); - const { id, style, className } = props; return (
(selectedRef = svg)} + ref={selectedRef} /> ); }; -export default GaugeChart; - GaugeChart.defaultProps = { - style: defaultStyle, marginInPercent: 0.05, cornerRadius: 6, nrOfLevels: 3, @@ -233,283 +120,4 @@ GaugeChart.propTypes = { animDelay: PropTypes.number, }; -// This function update arc's datas when component is mounting or when one of arc's props is updated -const setArcData = (props, nbArcsToDisplay, colorArray, arcData) => { - // We have to make a decision about number of arcs to display - // If arcsLength is setted, we choose arcsLength length instead of nrOfLevels - nbArcsToDisplay.current = props.arcsLength - ? props.arcsLength.length - : props.nrOfLevels; - - //Check if the number of colors equals the number of levels - //Otherwise make an interpolation - if (nbArcsToDisplay.current === props.colors.length) { - colorArray.current = props.colors; - } else { - colorArray.current = getColors(props, nbArcsToDisplay); - } - //The data that is used to create the arc - // Each arc could have hiw own value width arcsLength prop - arcData.current = []; - for (var i = 0; i < nbArcsToDisplay.current; i++) { - var arcDatum = { - value: - props.arcsLength && props.arcsLength.length > i - ? props.arcsLength[i] - : 1, - color: colorArray.current[i], - }; - arcData.current.push(arcDatum); - } -}; - -//Renders the chart, should be called every time the window is resized -const renderChart = ( - resize, - prevProps, - width, - margin, - height, - outerRadius, - g, - doughnut, - arcChart, - needle, - pieChart, - svg, - props, - container, - arcData -) => { - updateDimensions(props, container, margin, width, height); - //Set dimensions of svg element and translations - svg.current - .attr("width", width.current + margin.current.left + margin.current.right) - .attr( - "height", - height.current + margin.current.top + margin.current.bottom - ); - g.current.attr( - "transform", - "translate(" + margin.current.left + ", " + margin.current.top + ")" - ); - //Set the radius to lesser of width or height and remove the margins - //Calculate the new radius - calculateRadius(width, height, outerRadius, margin, g); - doughnut.current.attr( - "transform", - "translate(" + outerRadius.current + ", " + outerRadius.current + ")" - ); - //Setup the arc - arcChart.current - .outerRadius(outerRadius.current) - .innerRadius(outerRadius.current * (1 - props.arcWidth)) - .cornerRadius(props.cornerRadius) - .padAngle(props.arcPadding); - //Remove the old stuff - doughnut.current.selectAll(".arc").remove(); - needle.current.selectAll("*").remove(); - g.current.selectAll(".text-group").remove(); - //Draw the arc - var arcPaths = doughnut.current - .selectAll(".arc") - .data(pieChart.current(arcData.current)) - .enter() - .append("g") - .attr("class", "arc"); - arcPaths - .append("path") - .attr("d", arcChart.current) - .style("fill", function (d) { - return d.data.color; - }); - - drawNeedle( - resize, - prevProps, - props, - width, - needle, - container, - outerRadius, - g - ); - //Translate the needle starting point to the middle of the arc - needle.current.attr( - "transform", - "translate(" + outerRadius.current + ", " + outerRadius.current + ")" - ); -}; - -//Depending on the number of levels in the chart -//This function returns the same number of colors -const getColors = (props, nbArcsToDisplay) => { - const { colors } = props; - var colorScale = scaleLinear() - .domain([1, nbArcsToDisplay.current]) - .range([colors[0], colors[colors.length - 1]]) //Use the first and the last color as range - .interpolate(interpolateHsl); - var colorArray = []; - for (var i = 1; i <= nbArcsToDisplay.current; i++) { - colorArray.push(colorScale(i)); - } - return colorArray; -}; - -//If 'resize' is true then the animation does not play -const drawNeedle = ( - resize, - prevProps, - props, - width, - needle, - container, - outerRadius, - g -) => { - const { percent, needleColor, needleBaseColor, hideText, animate } = props; - var needleRadius = 15 * (width.current / 500), // Make the needle radius responsive - centerPoint = [0, -needleRadius / 2]; - //Draw the triangle - //var pathStr = `M ${leftPoint[0]} ${leftPoint[1]} L ${topPoint[0]} ${topPoint[1]} L ${rightPoint[0]} ${rightPoint[1]}`; - const prevPercent = prevProps ? prevProps.percent : 0; - var pathStr = calculateRotation(prevPercent || percent, outerRadius, width); - needle.current.append("path").attr("d", pathStr).attr("fill", needleColor); - //Add a circle at the bottom of needle - needle.current - .append("circle") - .attr("cx", centerPoint[0]) - .attr("cy", centerPoint[1]) - .attr("r", needleRadius) - .attr("fill", needleBaseColor); - if (!hideText) { - addText(percent, props, outerRadius, width, g); - } - //Rotate the needle - if (!resize && animate) { - needle.current - .transition() - .delay(props.animDelay) - .ease(easeElastic) - .duration(props.animateDuration) - .tween("progress", function () { - const currentPercent = interpolateNumber(prevPercent, percent); - return function (percentOfPercent) { - const progress = currentPercent(percentOfPercent); - return container.current - .select(`.needle path`) - .attr("d", calculateRotation(progress, outerRadius, width)); - }; - }); - } else { - container.current - .select(`.needle path`) - .attr("d", calculateRotation(percent, outerRadius, width)); - } -}; - -const calculateRotation = (percent, outerRadius, width) => { - var needleLength = outerRadius.current * 0.55, //TODO: Maybe it should be specified as a percentage of the arc radius? - needleRadius = 15 * (width.current / 500), - theta = percentToRad(percent), - centerPoint = [0, -needleRadius / 2], - topPoint = [ - centerPoint[0] - needleLength * Math.cos(theta), - centerPoint[1] - needleLength * Math.sin(theta), - ], - leftPoint = [ - centerPoint[0] - needleRadius * Math.cos(theta - Math.PI / 2), - centerPoint[1] - needleRadius * Math.sin(theta - Math.PI / 2), - ], - rightPoint = [ - centerPoint[0] - needleRadius * Math.cos(theta + Math.PI / 2), - centerPoint[1] - needleRadius * Math.sin(theta + Math.PI / 2), - ]; - var pathStr = `M ${leftPoint[0]} ${leftPoint[1]} L ${topPoint[0]} ${topPoint[1]} L ${rightPoint[0]} ${rightPoint[1]}`; - return pathStr; -}; - -//Returns the angle (in rad) for the given 'percent' value where percent = 1 means 100% and is 180 degree angle -const percentToRad = (percent) => { - return percent * Math.PI; -}; - -//Adds text undeneath the graft to display which percentage is the current one -const addText = (percentage, props, outerRadius, width, g) => { - const { formatTextValue, fontSize } = props; - var textPadding = 20; - const text = formatTextValue - ? formatTextValue(floatingNumber(percentage)) - : floatingNumber(percentage) + "%"; - g.current - .append("g") - .attr("class", "text-group") - .attr( - "transform", - `translate(${outerRadius.current}, ${ - outerRadius.current / 2 + textPadding - })` - ) - .append("text") - .text(text) - // this computation avoid text overflow. When formatted value is over 10 characters, we should reduce font size - .style("font-size", () => - fontSize - ? fontSize - : `${width.current / 11 / (text.length > 10 ? text.length / 10 : 1)}px` - ) - .style("fill", props.textColor) - .style("text-anchor", "middle"); -}; - -const floatingNumber = (value, maxDigits = 2) => { - return Math.round(value * 100 * 10 ** maxDigits) / 10 ** maxDigits; -}; - -const calculateRadius = (width, height, outerRadius, margin, g) => { - //The radius needs to be constrained by the containing div - //Since it is a half circle we are dealing with the height of the div - //Only needs to be half of the width, because the width needs to be 2 * radius - //For the whole arc to fit - - //First check if it is the width or the height that is the "limiting" dimension - if (width.current < 2 * height.current) { - //Then the width limits the size of the chart - //Set the radius to the width - the horizontal margins - outerRadius.current = - (width.current - margin.current.left - margin.current.right) / 2; - } else { - outerRadius.current = - height.current - margin.current.top - margin.current.bottom; - } - centerGraph(width, g, outerRadius, margin); -}; - -//Calculates new margins to make the graph centered -const centerGraph = (width, g, outerRadius, margin) => { - margin.current.left = - width.current / 2 - outerRadius.current + margin.current.right; - g.current.attr( - "transform", - "translate(" + margin.current.left + ", " + margin.current.top + ")" - ); -}; - -const updateDimensions = (props, container, margin, width, height) => { - //TODO: Fix so that the container is included in the component - const { marginInPercent } = props; - var divDimensions = container.current.node().getBoundingClientRect(), - divWidth = divDimensions.width, - divHeight = divDimensions.height; - - //Set the new width and horizontal margins - margin.current.left = divWidth * marginInPercent; - margin.current.right = divWidth * marginInPercent; - width.current = divWidth - margin.current.left - margin.current.right; - - margin.current.top = divHeight * marginInPercent; - margin.current.bottom = divHeight * marginInPercent; - height.current = - width.current / 2 - margin.current.top - margin.current.bottom; - //height.current = divHeight - margin.current.top - margin.current.bottom; -}; +export default GaugeChart; diff --git a/src/lib/GaugeChart/utils.js b/src/lib/GaugeChart/utils.js new file mode 100644 index 0000000..6d539c3 --- /dev/null +++ b/src/lib/GaugeChart/utils.js @@ -0,0 +1,282 @@ +import { + arc, + pie, + select, + easeElastic, + interpolateHsl, + interpolateNumber, +} from "d3"; + +// Helpers +const first = (array) => array[0] +const last = (array) => array[array.length - 1] + +// Constants +const startAngle = -Math.PI / 2; //Negative x-axis +const endAngle = Math.PI / 2; //Positive x-axis + +// Returns array of arc data (length and color) for pie and arc generator +const getArcData = ({ nrOfLevels, arcsLength, colors }) => { + // We have to make a decision about number of arcs to display + // If arcsLength is setted, we choose arcsLength length instead of nrOfLevels + const nbArcsToDisplay = arcsLength ? arcsLength.length : nrOfLevels; + + //Check if the number of colors equals the number of levels + //Otherwise make an interpolation + const arcColors = (nbArcsToDisplay === colors.length) + ? colors + : interpolateColors(first(colors), last(colors), nbArcsToDisplay); + + //The data that is used to create the arc + // Each arc could have hiw own value width arcsLength prop + return arcColors.map((color, index) => ({ + value: arcsLength ? arcsLength[index] : 1, + color, + })) +}; + +//Renders the chart +export const renderChart = ( + prevProps, + width, + margin, + height, + outerRadius, + g, + doughnut, + needle, + svg, + props, + container, +) => { + updateDimensions(props, container, margin, width, height); + + //Set dimensions of svg element and translations + svg.current + .attr("width", width.current + margin.current.left + margin.current.right) + .attr( + "height", + height.current + margin.current.top + margin.current.bottom + ); + g.current.attr( + "transform", + "translate(" + margin.current.left + ", " + margin.current.top + ")" + ); + //Set the radius to lesser of width or height and remove the margins + //Calculate the new radius + calculateRadius(width, height, outerRadius, margin, g); + doughnut.current.attr( + "transform", + "translate(" + outerRadius.current + ", " + outerRadius.current + ")" + ); + //Setup the arc + const arcChart = arc() + .outerRadius(outerRadius.current) + .innerRadius(outerRadius.current * (1 - props.arcWidth)) + .cornerRadius(props.cornerRadius) + .padAngle(props.arcPadding); + //Remove the old stuff + doughnut.current.selectAll(".arc").remove(); + needle.current.selectAll("*").remove(); + g.current.selectAll(".text-group").remove(); + //Set up the pie generator + const pieChart = pie() + .value((d) => d.value) + .startAngle(startAngle) + .endAngle(endAngle) + .sort(null); + //Draw the arc + var arcPaths = doughnut.current + .selectAll(".arc") + .data(pieChart(getArcData(props))) + .enter() + .append("g") + .attr("class", "arc"); + arcPaths + .append("path") + .attr("d", arcChart) + .style("fill", function (d) { + return d.data.color; + }); + + drawNeedle( + prevProps, + props, + width, + needle, + container, + outerRadius, + g + ); + //Translate the needle starting point to the middle of the arc + needle.current.attr( + "transform", + "translate(" + outerRadius.current + ", " + outerRadius.current + ")" + ); +}; + +const interpolateColors = (startColor, endColor, length) => { + const interpolator = interpolateHsl(startColor, endColor) + + return length > 1 + ? Array.from({ length }, (_, i) => interpolator(i / (length - 1))) + : [startColor] +} + +const drawNeedle = ( + prevProps, + props, + width, + needle, + container, + outerRadius, + g +) => { + const { percent, needleColor, needleBaseColor, hideText, animate } = props; + var needleRadius = 15 * (width.current / 500), // Make the needle radius responsive + centerPoint = [0, -needleRadius / 2]; + //Draw the triangle + //var pathStr = `M ${leftPoint[0]} ${leftPoint[1]} L ${topPoint[0]} ${topPoint[1]} L ${rightPoint[0]} ${rightPoint[1]}`; + const prevPercent = prevProps ? prevProps.percent : 0; + var pathStr = calculateRotation(prevPercent || percent, outerRadius, width); + needle.current.append("path").attr("d", pathStr).attr("fill", needleColor); + //Add a circle at the bottom of needle + needle.current + .append("circle") + .attr("cx", centerPoint[0]) + .attr("cy", centerPoint[1]) + .attr("r", needleRadius) + .attr("fill", needleBaseColor); + if (!hideText) { + addText(percent, props, outerRadius, width, g); + } + //Rotate the needle + if (animate) { + needle.current + .transition() + .delay(props.animDelay) + .ease(easeElastic) + .duration(props.animateDuration) + .tween("progress", function () { + const currentPercent = interpolateNumber(prevPercent, percent); + return function (percentOfPercent) { + const progress = currentPercent(percentOfPercent); + return container.current + .select(`.needle path`) + .attr("d", calculateRotation(progress, outerRadius, width)); + }; + }); + } else { + container.current + .select(`.needle path`) + .attr("d", calculateRotation(percent, outerRadius, width)); + } +}; + +const calculateRotation = (percent, outerRadius, width) => { + var needleLength = outerRadius.current * 0.55, //TODO: Maybe it should be specified as a percentage of the arc radius? + needleRadius = 15 * (width.current / 500), + theta = percentToRad(percent), + centerPoint = [0, -needleRadius / 2], + topPoint = [ + centerPoint[0] - needleLength * Math.cos(theta), + centerPoint[1] - needleLength * Math.sin(theta), + ], + leftPoint = [ + centerPoint[0] - needleRadius * Math.cos(theta - Math.PI / 2), + centerPoint[1] - needleRadius * Math.sin(theta - Math.PI / 2), + ], + rightPoint = [ + centerPoint[0] - needleRadius * Math.cos(theta + Math.PI / 2), + centerPoint[1] - needleRadius * Math.sin(theta + Math.PI / 2), + ]; + var pathStr = `M ${leftPoint[0]} ${leftPoint[1]} L ${topPoint[0]} ${topPoint[1]} L ${rightPoint[0]} ${rightPoint[1]}`; + return pathStr; +}; + +//Returns the angle (in rad) for the given 'percent' value where percent = 1 means 100% and is 180 degree angle +const percentToRad = (percent) => { + return percent * Math.PI; +}; + +//Adds text undeneath the graft to display which percentage is the current one +const addText = (percentage, props, outerRadius, width, g) => { + const { formatTextValue, fontSize } = props; + var textPadding = 20; + const text = formatTextValue + ? formatTextValue(floatingNumber(percentage)) + : floatingNumber(percentage) + "%"; + g.current + .append("g") + .attr("class", "text-group") + .attr( + "transform", + `translate(${outerRadius.current}, ${ + outerRadius.current / 2 + textPadding + })` + ) + .append("text") + .text(text) + // this computation avoid text overflow. When formatted value is over 10 characters, we should reduce font size + .style("font-size", () => + fontSize + ? fontSize + : `${width.current / 11 / (text.length > 10 ? text.length / 10 : 1)}px` + ) + .style("fill", props.textColor) + .style("text-anchor", "middle"); +}; + +const floatingNumber = (value, maxDigits = 2) => { + return Math.round(value * 100 * 10 ** maxDigits) / 10 ** maxDigits; +}; + +const calculateRadius = (width, height, outerRadius, margin, g) => { + //The radius needs to be constrained by the containing div + //Since it is a half circle we are dealing with the height of the div + //Only needs to be half of the width, because the width needs to be 2 * radius + //For the whole arc to fit + + //First check if it is the width or the height that is the "limiting" dimension + if (width.current < 2 * height.current) { + //Then the width limits the size of the chart + //Set the radius to the width - the horizontal margins + outerRadius.current = + (width.current - margin.current.left - margin.current.right) / 2; + } else { + outerRadius.current = + height.current - margin.current.top - margin.current.bottom; + } + centerGraph(width, g, outerRadius, margin); +}; + +//Calculates new margins to make the graph centered +const centerGraph = (width, g, outerRadius, margin) => { + margin.current.left = + width.current / 2 - outerRadius.current + margin.current.right; + g.current.attr( + "transform", + "translate(" + margin.current.left + ", " + margin.current.top + ")" + ); +}; + +const updateDimensions = (props, container, margin, width, height) => { + //TODO: Fix so that the container is included in the component + const { marginInPercent } = props; + var divDimensions = process.env.NODE_ENV === 'test' + ? { width: 500, height: 250 } + : container.current.node().getBoundingClientRect(); + var divWidth = divDimensions.width; + var divHeight = divDimensions.height; + + //Set the new width and horizontal margins + margin.current.left = divWidth * marginInPercent; + margin.current.right = divWidth * marginInPercent; + width.current = divWidth - margin.current.left - margin.current.right; + + margin.current.top = divHeight * marginInPercent; + margin.current.bottom = divHeight * marginInPercent; + height.current = + width.current / 2 - margin.current.top - margin.current.bottom; + //height.current = divHeight - margin.current.top - margin.current.bottom; +}; diff --git a/yarn.lock b/yarn.lock index 70903b4..7bf7708 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8525,16 +8525,7 @@ lodash.uniq@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" -"lodash@>=3.5 <5", lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.5: - version "4.17.11" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" - -lodash@^4.17.13: - version "4.17.15" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" - integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== - -lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: +"lodash@>=3.5 <5", lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.5, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -10369,6 +10360,11 @@ prepend-http@^1.0.0: resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= +prettier@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.6.2.tgz#e26d71a18a74c3d0f0597f55f01fb6c06c206032" + integrity sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew== + pretty-bytes@^5.3.0: version "5.6.0" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"