Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 },
]

<div style={{ width: 420, height: 420 }}>
<PieChart
data={data}
valueAccessor={d => d.value}
labelAccessor={d => d.label}
/>
</div>

{/* Donut variant */}
<div style={{ width: 420, height: 420 }}>
<PieChart
data={data}
valueAccessor={d => d.value}
labelAccessor={d => d.label}
innerRadius={0.55}
colors={['#e74c3c', '#3498db', '#2ecc71', '#f39c12']}
/>
</div>
```

![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
Expand Down
Binary file added docs/piechart.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
106 changes: 106 additions & 0 deletions src/Charts/PieChart.js
Original file line number Diff line number Diff line change
@@ -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 (
<div className="PieChart" ref={ref} style={{ width: '100%', height: '100%' }}>
<Chart dimensions={dimensions} label="Pie chart">
<g transform={`translate(${cx}, ${cy})`}>
{arcs.map((arc, i) => {
const label = labelAccessor ? labelAccessor(data[i]) : String(i)
const sliceAngle = arc.endAngle - arc.startAngle
return (
<g key={i} className="PieChart__slice-group">
<path
className="PieChart__slice"
d={arcGenerator(arc)}
style={{ fill: colorScale(label) }}
/>
{displayLabels && sliceAngle >= MIN_LABEL_ANGLE && (
<text
className="PieChart__label"
transform={`translate(${labelArc.centroid(arc)})`}
style={{ textAnchor: 'middle', dominantBaseline: 'middle' }}
>
{label}
</text>
)}
</g>
)
})}
</g>
</Chart>
</div>
)
}

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
1 change: 1 addition & 0 deletions src/Charts/index.js
Original file line number Diff line number Diff line change
@@ -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';

94 changes: 94 additions & 0 deletions src/__tests__/PieChart.test.js
Original file line number Diff line number Diff line change
@@ -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(
<PieChart
data={makeData()}
valueAccessor={d => d.value}
labelAccessor={d => d.label}
/>
)
expect(container.firstChild).toBeInTheDocument()
})

it('wraps content in a div with class "PieChart"', () => {
const { container } = render(
<PieChart data={makeData()} valueAccessor={d => d.value} />
)
expect(container.querySelector('.PieChart')).toBeInTheDocument()
})

it('renders an SVG chart', () => {
const { container } = render(
<PieChart data={makeData()} valueAccessor={d => d.value} />
)
expect(container.querySelector('svg.Chart')).toBeInTheDocument()
})

it('renders one slice per data point', () => {
const data = makeData()
const { container } = render(
<PieChart data={data} valueAccessor={d => d.value} />
)
expect(container.querySelectorAll('.PieChart__slice')).toHaveLength(data.length)
})

it('renders nothing for empty data', () => {
const { container } = render(
<PieChart data={[]} valueAccessor={d => d.value} />
)
expect(container.firstChild).toBeNull()
})

it('renders labels when labelAccessor is provided', () => {
const data = makeData()
const { container } = render(
<PieChart
data={data}
valueAccessor={d => d.value}
labelAccessor={d => d.label}
/>
)
expect(container.querySelectorAll('.PieChart__label')).toHaveLength(data.length)
})

it('hides labels when showLabels is false', () => {
const { container } = render(
<PieChart
data={makeData()}
valueAccessor={d => 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(<PieChart data={defaultData} />)).not.toThrow()
})

it('renders a donut when innerRadius is provided', () => {
const { container } = render(
<PieChart
data={makeData()}
valueAccessor={d => d.value}
innerRadius={0.5}
/>
)
// All slices should still render
expect(container.querySelectorAll('.PieChart__slice')).toHaveLength(makeData().length)
})
})
Loading