diff --git a/README.md b/README.md index 0d053a9..3d5a917 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A lightweight React + D3 chart library for building responsive, accessible data - **ScatterPlot** — correlation scatter chart - **Histogram** — frequency distribution chart - **BarChart** — categorical bar chart +- **PieChart** — pie / donut chart for part-to-whole comparisons ## Installation @@ -193,6 +194,54 @@ const data = [ --- +### PieChart + +Renders a pie or donut chart for part-to-whole comparisons. Labels are automatically hidden on slices narrower than ~20° to prevent overlap. + +```jsx +import { PieChart } from 'quick-charts' + +const data = [ + { label: 'Apples', value: 32 }, + { label: 'Bananas', value: 21 }, + { label: 'Cherries', value: 18 }, + { label: 'Dates', value: 14 }, +] + +
+ d.value} + labelAccessor={d => d.label} + /> +
+ +{/* Donut variant */} +
+ d.value} + labelAccessor={d => d.label} + innerRadius={0.55} + colors={['#e74c3c', '#3498db', '#2ecc71', '#f39c12']} + /> +
+``` + +![Pie chart showing fruit distribution](docs/piechart.png) + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `data` | `Array` | — | Array of data objects | +| `valueAccessor` | `Function` | `d => d.value` | Returns a numeric value from each datum — determines slice size | +| `labelAccessor` | `Function` | `d => d.label` | Returns a label string from each datum — used for color mapping and slice text | +| `colors` | `String[]` | `d3.schemeSet2` | Array of color strings for the slices | +| `innerRadius` | `Number` | `0` | Donut hole size as a fraction of the outer radius (0 = full pie, 0.5 = half donut) | +| `padAngle` | `Number` | `0.02` | Gap between slices in radians | +| `showLabels` | `Boolean` | `true` | Show label text inside each slice when `labelAccessor` is provided | + +--- + ## Full Example ```jsx diff --git a/docs/piechart.png b/docs/piechart.png new file mode 100644 index 0000000..d63a3d9 Binary files /dev/null and b/docs/piechart.png differ diff --git a/src/Charts/PieChart.js b/src/Charts/PieChart.js new file mode 100644 index 0000000..c50323e --- /dev/null +++ b/src/Charts/PieChart.js @@ -0,0 +1,106 @@ +import React from "react" +import PropTypes from "prop-types" +import * as d3 from "d3" + +import Chart from "../Components/Chart" +import { useChartDimensions, accessorPropsType } from "../Utils/utils" + +const defaultMargin = { marginTop: 20, marginRight: 20, marginBottom: 20, marginLeft: 20 } + +const PieChart = ({ + data, valueAccessor, labelAccessor, + colors, innerRadius, padAngle, showLabels, +}) => { + const [ref, dimensions] = useChartDimensions(defaultMargin) + + if (!data || data.length === 0) return null + + const outerRadius = Math.min(dimensions.boundedWidth, dimensions.boundedHeight) / 2 + const cx = dimensions.boundedWidth / 2 + const cy = dimensions.boundedHeight / 2 + + const colorScale = d3.scaleOrdinal( + Array.isArray(colors) ? colors : d3.schemeSet2 + ) + + const resolvedInnerRadius = typeof innerRadius === 'number' + ? outerRadius * Math.min(Math.max(innerRadius, 0), 0.95) + : 0 + + const pieGenerator = d3.pie() + .value(valueAccessor) + .padAngle(padAngle !== undefined ? padAngle : 0.02) + .sort(null) + + const arcGenerator = d3.arc() + .innerRadius(resolvedInnerRadius) + .outerRadius(outerRadius) + + // Labels sit at 80% of the outer radius (or midpoint for donuts) + const labelRadius = resolvedInnerRadius > 0 + ? (resolvedInnerRadius + outerRadius) / 2 + : outerRadius * 0.65 + + const labelArc = d3.arc() + .innerRadius(labelRadius) + .outerRadius(labelRadius) + + const arcs = pieGenerator(data) + const displayLabels = showLabels !== false && typeof labelAccessor === 'function' + // Hide labels on slices narrower than ~20° to prevent overlap + const MIN_LABEL_ANGLE = 0.35 + + return ( +
+ + + {arcs.map((arc, i) => { + const label = labelAccessor ? labelAccessor(data[i]) : String(i) + const sliceAngle = arc.endAngle - arc.startAngle + return ( + + + {displayLabels && sliceAngle >= MIN_LABEL_ANGLE && ( + + {label} + + )} + + ) + })} + + +
+ ) +} + +PieChart.propTypes = { + data: PropTypes.array, + /** Returns a numeric value from each datum — determines slice size. */ + valueAccessor: accessorPropsType, + /** Returns a label string from each datum — used for color mapping and optional text. */ + labelAccessor: accessorPropsType, + /** Array of color strings for the slices. Defaults to d3.schemeSet2. */ + colors: PropTypes.arrayOf(PropTypes.string), + /** Donut hole size as a fraction of the outer radius (0 = full pie, 0.5 = half donut). Default: 0. */ + innerRadius: PropTypes.number, + /** Gap between slices in radians. Default: 0.02. */ + padAngle: PropTypes.number, + /** Show label text inside each slice. Default: true when labelAccessor is provided. */ + showLabels: PropTypes.bool, +} + +PieChart.defaultProps = { + valueAccessor: d => d.value, + labelAccessor: d => d.label, +} + +export default PieChart diff --git a/src/Charts/index.js b/src/Charts/index.js index 37fc85d..d7f762c 100644 --- a/src/Charts/index.js +++ b/src/Charts/index.js @@ -1,5 +1,6 @@ export {default as BarChart} from './BarChart'; export {default as Histogram} from './Histogram'; +export {default as PieChart} from './PieChart'; export {default as ScatterPlot} from './ScatterPlot'; export {default as Timeline} from './Timeline'; diff --git a/src/__tests__/PieChart.test.js b/src/__tests__/PieChart.test.js new file mode 100644 index 0000000..6ce22f3 --- /dev/null +++ b/src/__tests__/PieChart.test.js @@ -0,0 +1,94 @@ +import React from 'react' +import { render } from '@testing-library/react' +import PieChart from '../Charts/PieChart' + +const makeData = () => [ + { label: 'Apples', value: 30 }, + { label: 'Bananas', value: 20 }, + { label: 'Cherries', value: 15 }, + { label: 'Dates', value: 10 }, + { label: 'Elderberries', value: 25 }, +] + +describe('PieChart', () => { + it('renders without crashing', () => { + const { container } = render( + d.value} + labelAccessor={d => d.label} + /> + ) + expect(container.firstChild).toBeInTheDocument() + }) + + it('wraps content in a div with class "PieChart"', () => { + const { container } = render( + d.value} /> + ) + expect(container.querySelector('.PieChart')).toBeInTheDocument() + }) + + it('renders an SVG chart', () => { + const { container } = render( + d.value} /> + ) + expect(container.querySelector('svg.Chart')).toBeInTheDocument() + }) + + it('renders one slice per data point', () => { + const data = makeData() + const { container } = render( + d.value} /> + ) + expect(container.querySelectorAll('.PieChart__slice')).toHaveLength(data.length) + }) + + it('renders nothing for empty data', () => { + const { container } = render( + d.value} /> + ) + expect(container.firstChild).toBeNull() + }) + + it('renders labels when labelAccessor is provided', () => { + const data = makeData() + const { container } = render( + d.value} + labelAccessor={d => d.label} + /> + ) + expect(container.querySelectorAll('.PieChart__label')).toHaveLength(data.length) + }) + + it('hides labels when showLabels is false', () => { + const { container } = render( + d.value} + labelAccessor={d => d.label} + showLabels={false} + /> + ) + expect(container.querySelector('.PieChart__label')).toBeNull() + }) + + it('uses default accessors when none are supplied', () => { + const defaultData = makeData().map(d => ({ label: d.label, value: d.value })) + expect(() => render()).not.toThrow() + }) + + it('renders a donut when innerRadius is provided', () => { + const { container } = render( + d.value} + innerRadius={0.5} + /> + ) + // All slices should still render + expect(container.querySelectorAll('.PieChart__slice')).toHaveLength(makeData().length) + }) +})