From 1b4579509c2d2444d050a1fc95122b3d8acc76a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 07:24:06 +0000 Subject: [PATCH] Add PieChart component with donut support, tests, and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PieChart renders pie/donut charts using d3.pie() and d3.arc() - Props: valueAccessor, labelAccessor, colors, innerRadius (0–1 fraction for donut hole), padAngle, showLabels - Labels auto-hidden on slices narrower than ~20° to prevent overlap - 9 tests covering rendering, slices, labels, donut mode, and empty data - Added piechart.png screenshot and README section with prop table https://claude.ai/code/session_01Bf9veNX9mqfMqrCEGnujAo --- README.md | 49 +++++++++++++++ docs/piechart.png | Bin 0 -> 23608 bytes src/Charts/PieChart.js | 106 +++++++++++++++++++++++++++++++++ src/Charts/index.js | 1 + src/__tests__/PieChart.test.js | 94 +++++++++++++++++++++++++++++ 5 files changed, 250 insertions(+) create mode 100644 docs/piechart.png create mode 100644 src/Charts/PieChart.js create mode 100644 src/__tests__/PieChart.test.js 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 0000000000000000000000000000000000000000..d63a3d9e1dc4e8d1c565663c0e5ac3496f393c98 GIT binary patch literal 23608 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 literal 0 HcmV?d00001 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) + }) +})