Pie Chart
Slices of a whole: a pie or a donut, rings inside rings, slices drawn out, padded or rounded, with labels inside or outside and a figure in the middle.
import 'package:flutter/widgets.dart';
import 'ui/card.dart';
import 'ui/chart.dart';
import 'ui/pie_chart.dart';
typedef _Browser = ({String browser, double visitors});
const _browsers = <_Browser>[
(browser: 'Chrome', visitors: 275),
(browser: 'Safari', visitors: 200),
(browser: 'Firefox', visitors: 187),
(browser: 'Edge', visitors: 173),
(browser: 'Other', visitors: 90),
];
class PieChartDemo extends StatelessWidget {
const PieChartDemo({super.key});
@override
Widget build(BuildContext context) => SizedBox(
width: 300,
child: Card(
children: [
const CardHeader(title: Text('Visitors'), description: Text('By browser, this month')),
CardContent(
child: Center(
child: PieChart<_Browser>(
data: _browsers,
size: 200,
semanticLabel: 'Visitors by browser',
center: const Text('925'),
children: [
ChartTooltip(hideLabel: true),
ChartLegend(),
Pie(value: (r) => r.visitors, name: (r) => r.browser, innerRadius: .6),
],
),
),
),
],
),
);
}
Add it
In your Flutter project:
$
ashlar add pie-chart
It writes theme.dart, palette.dart, chart.dart and pie_chart.dart into lib/ui, keeping any you already have. It needs chart, which come with it.
API
Read from the source above: each parameter with its type, its default, and what it is for.
Pieclass
One ring of a pie: a slice per row.
| Parameter | Type | Default | What it is for |
|---|---|---|---|
valuerequired |
ChartValue<T> |
||
namerequired |
String Function(T row) |
Each slice's name, for the legend, the tooltip and a screen reader. | |
data |
List<T>? |
Rows of its own, for a second ring that splits the whole another way. | |
colorOf |
Color? Function(T row)? |
A colour per slice; null takes the theme's chart colours in turn. | |
innerRadius |
double? |
Where the ring starts and ends, as shares of the radius: 0.6 to 1 is a donut. Null: a whole pie, or an even share of the radius with other rings. | |
outerRadius |
double? |
Where the ring starts and ends, as shares of the radius: 0.6 to 1 is a donut. Null: a whole pie, or an even share of the radius with other rings. | |
paddingAngle |
double |
0 |
A gap between slices, in degrees. |
cornerRadius |
double |
0 |
The rounding of each slice's corners. |
activeIndex |
int? |
One slice drawn out from the rest: the one being talked about. | |
labels |
List<ChartLabels<T>> |
const [] |
Text on the slices: ChartLabelPosition.inside within them, anything else outside. |
labelLine |
bool |
true |
A line from each slice to its outside label. |
separators |
bool |
true |
A thin line of the page between slices. |
PieChartclass
Slices of a whole: put a Pie in it, two for a ring inside a ring.
| Parameter | Type | Default | What it is for |
|---|---|---|---|
datarequired |
List<T> |
||
childrenrequired |
List<ChartPart<T>> |
||
size |
double |
250 |
Its width and height, without the legend. |
center |
Widget? |
In the middle of a donut: the figure the chart is about. | |
onSelect |
ValueChanged<ChartSelection<T>?>? |
||
format | String Function(num value)? | ||
semanticLabel | String? |
Source
What lands in your app. It is yours from then on: change it as you would your own code, and ashlar add
will not write over it.
lib/ui/pie_chart.dart 487 lines
// Ashlar UI: Pie chart. Copied into your app by `ashlar add pie-chart`; it is yours to change.
// ashlar: ignore-file. Laid out the same in any language, as charts are read.
//
// Slices of a whole, as a pie or a donut, a ring inside a ring if you like: your rows and
// a Pie. The tooltip, legend and labels are chart's.
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import 'chart.dart';
import 'theme.dart';
/// One ring of a pie: a slice per row.
class Pie<T> extends ChartPart<T> implements ChartColumn<T> {
const Pie({
required this.value,
required this.name,
this.data,
this.colorOf,
this.innerRadius,
this.outerRadius,
this.paddingAngle = 0,
this.cornerRadius = 0,
this.activeIndex,
this.labels = const [],
this.labelLine = true,
this.separators = true,
});
final ChartValue<T> value;
/// Each slice's name, for the legend, the tooltip and a screen reader.
final String Function(T row) name;
/// Rows of its own, for a second ring that splits the whole another way.
final List<T>? data;
/// A colour per slice; null takes the theme's chart colours in turn.
final Color? Function(T row)? colorOf;
/// Where the ring starts and ends, as shares of the radius: 0.6 to 1 is a donut.
/// Null: a whole pie, or an even share of the radius with other rings.
final double? innerRadius, outerRadius;
/// A gap between slices, in degrees.
final double paddingAngle;
/// The rounding of each slice's corners.
final double cornerRadius;
/// One slice drawn out from the rest: the one being talked about.
final int? activeIndex;
/// Text on the slices: [ChartLabelPosition.inside] within them, anything else outside.
final List<ChartLabels<T>> labels;
/// A line from each slice to its outside label.
final bool labelLine;
/// A thin line of the page between slices.
final bool separators;
@override
String get columnName => 'Value';
@override
num? columnValue(T row) => value(row);
}
/// Slices of a whole: put a [Pie] in it, two for a ring inside a ring.
///
/// ```dart
/// PieChart<Browser>(
/// data: browsers,
/// center: Text('1,125'),
/// children: [
/// ChartTooltip(hideLabel: true),
/// Pie(value: (r) => r.visitors, name: (r) => r.browser, innerRadius: .6),
/// ],
/// )
/// ```
class PieChart<T> extends StatefulWidget {
const PieChart({
super.key,
required this.data,
required this.children,
this.size = 250,
this.center,
this.onSelect,
this.format,
this.semanticLabel,
});
final List<T> data;
final List<ChartPart<T>> children;
/// Its width and height, without the legend.
final double size;
/// In the middle of a donut: the figure the chart is about.
final Widget? center;
final ValueChanged<ChartSelection<T>?>? onSelect;
final String Function(num value)? format;
final String? semanticLabel;
@override
State<PieChart<T>> createState() => _PieState<T>();
}
class _PieState<T> extends State<PieChart<T>>
with TickerProviderStateMixin, ChartGrowth<PieChart<T>> {
int? _at;
int _ring = 0;
final _hidden = <int>{};
ChartSelection<T>? _selected;
String Function(num) get _format => widget.format ?? chartPlain;
@override
Widget build(BuildContext context) {
final t = AshlarTheme.of(context);
final c = t.colors;
final pies = widget.children.whereType<Pie<T>>().toList();
final tooltip = widget.children.whereType<ChartTooltip<T>>().firstOrNull;
final legend = widget.children.whereType<ChartLegend<T>>().firstOrNull;
final outside = pies.any(
(p) => p.labels.any((l) => l.position != ChartLabelPosition.inside),
);
final margin = outside ? 40.0 : 4.0;
final r = widget.size / 2 - margin;
final centre = Offset(widget.size / 2, widget.size / 2);
// Each ring's slices: rows, values, colours and angles (clockwise from the top).
final rings = <_Ring<T>>[];
for (final (k, p) in pies.indexed) {
final rows = p.data ?? widget.data;
final values = [
for (final (i, row) in rows.indexed)
k == 0 && _hidden.contains(i) ? 0.0 : (p.value(row)?.toDouble() ?? 0),
];
final total = values.fold<double>(0, (a, v) => a + math.max(0, v));
final inner = p.innerRadius ??
(pies.length == 1 ? 0.0 : (pies.length - 1 - k) / pies.length * .9);
final outer = p.outerRadius ??
(pies.length == 1 ? 1.0 : (pies.length - k) / pies.length);
rings.add(
_Ring(
part: p,
rows: rows,
values: values,
total: total,
colors: [
for (final (i, row) in rows.indexed)
p.colorOf?.call(row) ?? c.charts[i % c.charts.length],
],
inner: inner * r,
outer: outer * r,
labels: [
for (final l in p.labels)
(
l,
[
for (final (i, row) in rows.indexed)
l.text != null ? l.text!(row, values[i]) : _format(values[i]),
],
),
],
),
);
}
(int, int)? sliceAt(Offset p) {
final d = p - centre;
var a = math.atan2(d.dy, d.dx) + math.pi / 2;
if (a < 0) a += 2 * math.pi;
for (final (k, ring) in rings.indexed) {
if (d.distance > ring.outer + 8 || d.distance < ring.inner) continue;
var run = 0.0;
for (final (i, v) in ring.values.indexed) {
run += ring.total == 0 ? 0 : math.max(0, v) / ring.total * 2 * math.pi;
if (a <= run) return (k, i);
}
}
return null;
}
final first = rings.firstOrNull;
final entries = first == null
? <ChartLegendEntry>[]
: [
for (final (i, row) in first.rows.indexed)
ChartLegendEntry(
label: first.part.name(row),
color: first.colors[i],
shown: !_hidden.contains(i),
onToggle: legend?.toggle ?? false
? () => setState(() {
if (!_hidden.remove(i) &&
_hidden.length < first.rows.length - 1) {
_hidden.add(i);
}
})
: null,
),
];
final legendView = legend == null
? null
: (legend.builder ?? (context, e) => ChartLegendView(entries: e))(
context,
entries,
);
Widget? tip;
if (_at != null && tooltip != null && _ring < rings.length) {
final ring = rings[_ring];
final row = ring.rows[_at!];
final name = ring.part.name(row);
final v = ring.values[_at!];
final items = [
ChartTooltipItem(
label: name,
value: v,
text: tooltip.formatter?.call(v, name, row) ?? _format(v),
color: ring.colors[_at!],
),
];
final data = ChartTooltipData<T>(
title: tooltip.labelFormatter?.call(name, row) ?? name,
items: items,
row: row,
index: _at,
);
tip = tooltip.builder?.call(context, data) ??
ChartTooltipCard(
title: tooltip.hideLabel ? null : data.title,
items: items,
indicator: tooltip.hideIndicator ? ChartIndicator.none : tooltip.indicator,
);
}
return Semantics(
container: true,
label: widget.semanticLabel ?? 'Chart',
value: [
for (final ring in rings)
for (final (i, row) in ring.rows.indexed)
'${ring.part.name(row)} ${_format(ring.values[i])}',
].join(', '),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (legendView != null && legend!.position == ChartLegendPosition.top) ...[
legendView,
const SizedBox(height: 12),
],
MouseRegion(
onHover: (e) => setState(() {
final hit = sliceAt(e.localPosition);
_ring = hit?.$1 ?? 0;
_at = hit?.$2;
}),
onExit: (_) => setState(() => _at = null),
child: GestureDetector(
onTapDown: (d) => setState(() {
final hit = sliceAt(d.localPosition);
_ring = hit?.$1 ?? 0;
_at = hit?.$2;
}),
onTapUp: (d) {
if (widget.onSelect == null) return;
final hit = sliceAt(d.localPosition);
if (hit == null) return;
final pick = ChartSelection<T>(
index: hit.$2,
row: rings[hit.$1].rows[hit.$2],
);
setState(() => _selected = _selected == pick ? null : pick);
widget.onSelect!(_selected);
},
child: SizedBox.square(
dimension: widget.size,
child: AnimatedBuilder(
animation: grow,
builder: (context, _) => Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: CustomPaint(
painter: _PiePainter(
rings: rings,
centre: centre,
at: _at,
atRing: _ring,
selected: _selected?.index,
gap: c.background,
sweep: grown,
labelLine: c.mutedForeground,
text: t.text.xs.copyWith(color: c.foreground),
inside: t.text.xs.copyWith(
color: c.background,
fontWeight: FontWeight.w600,
),
format: _format,
),
),
),
if (widget.center != null)
Positioned.fill(
child: IgnorePointer(
child: Center(
child: DefaultTextStyle.merge(
style: t.text.xl.copyWith(
fontWeight: FontWeight.w700,
color: c.foreground,
),
textAlign: TextAlign.center,
child: widget.center!,
),
),
),
),
if (tip != null)
Positioned(
top: 0,
left: widget.size * .55,
child: IgnorePointer(child: tip),
),
],
),
),
),
),
),
if (legendView != null &&
legend!.position == ChartLegendPosition.bottom) ...[
const SizedBox(height: 12),
legendView,
],
],
),
);
}
}
class _Ring<T> {
_Ring({
required this.part,
required this.rows,
required this.values,
required this.total,
required this.colors,
required this.inner,
required this.outer,
required this.labels,
});
final Pie<T> part;
/// Each label's text per slice, written while building.
final List<(ChartLabels<T>, List<String?>)> labels;
final List<T> rows;
final List<double> values;
final double total, inner, outer;
final List<Color> colors;
}
class _PiePainter extends CustomPainter {
_PiePainter({
required this.rings,
required this.centre,
required this.at,
required this.atRing,
required this.selected,
required this.gap,
required this.sweep,
required this.labelLine,
required this.text,
required this.inside,
required this.format,
});
final List<_Ring<dynamic>> rings;
final Offset centre;
final int? at, selected;
final int atRing;
final Color gap, labelLine;
final double sweep;
final TextStyle text, inside;
final String Function(num) format;
@override
void paint(Canvas canvas, Size size) {
for (final (k, ring) in rings.indexed) {
if (ring.total <= 0) continue;
final p = ring.part;
final pad = p.paddingAngle * math.pi / 180;
var start = -math.pi / 2;
final active = p.activeIndex ?? (k == atRing ? at : null) ?? (k == 0 ? selected : null);
for (final (i, v) in ring.values.indexed) {
final a = math.max(0, v) / ring.total * 2 * math.pi * sweep;
if (a <= 0) continue;
final out = ring.outer + (i == active ? 8 : 0);
final from = start + pad / 2, to = start + a - pad / 2;
if (to > from) {
final path = _slice(centre, ring.inner, out, from, to, p.cornerRadius);
canvas.drawPath(path, Paint()..color = ring.colors[i]);
if (p.separators && p.paddingAngle == 0 && ring.values.length > 1) {
canvas.drawPath(
path,
Paint()
..color = gap
..style = PaintingStyle.stroke
..strokeWidth = 1.5,
);
}
}
// Labels, once the pie is whole.
if (sweep == 1) {
final mid = start + a / 2;
final dir = Offset(math.cos(mid), math.sin(mid));
for (final (l, texts) in ring.labels) {
final s = texts[i];
if (s == null) continue;
if (l.position == ChartLabelPosition.inside) {
final at = centre + dir * ((ring.inner + out) / 2);
chartText(canvas, s, at, inside.copyWith(color: l.color));
} else {
final edge = centre + dir * out;
final at = centre + dir * (out + 14 + l.offset);
if (p.labelLine) {
canvas.drawLine(
edge,
centre + dir * (out + 10 + l.offset),
Paint()
..color = labelLine
..strokeWidth = 1,
);
}
chartText(
canvas,
s,
at,
text.copyWith(color: l.color),
Alignment(dir.dx < -.2 ? 1 : (dir.dx > .2 ? -1 : 0), dir.dy < -.2 ? 1 : (dir.dy > .2 ? -1 : 0)),
);
}
}
}
start += a;
}
}
}
@override
bool shouldRepaint(_PiePainter old) => true;
}
/// A ring's slice between two radii and two angles, its corners rounded by [round].
Path _slice(Offset c, double inner, double outer, double from, double to, double round) {
final p = Path();
if (round <= 0 || to - from < .02) {
p.arcTo(Rect.fromCircle(center: c, radius: outer), from, to - from, true);
if (inner > 0) {
p.arcTo(Rect.fromCircle(center: c, radius: inner), to, from - to, false);
} else {
p.lineTo(c.dx, c.dy);
}
return p..close();
}
final r = math.min(round, (outer - inner) / 2);
final ao = r / outer, ai = inner > 0 ? r / inner : 0.0;
Offset at(double rad, double a) => c + Offset(math.cos(a), math.sin(a)) * rad;
p.moveTo(at(outer - r, from).dx, at(outer - r, from).dy);
p.quadraticBezierTo(at(outer, from).dx, at(outer, from).dy, at(outer, from + ao).dx, at(outer, from + ao).dy);
p.arcTo(Rect.fromCircle(center: c, radius: outer), from + ao, to - from - 2 * ao, false);
p.quadraticBezierTo(at(outer, to).dx, at(outer, to).dy, at(outer - r, to).dx, at(outer - r, to).dy);
if (inner > 0) {
p.lineTo(at(inner + r, to).dx, at(inner + r, to).dy);
p.quadraticBezierTo(at(inner, to).dx, at(inner, to).dy, at(inner, to - ai).dx, at(inner, to - ai).dy);
p.arcTo(Rect.fromCircle(center: c, radius: inner), to - ai, -(to - from - 2 * ai), false);
p.quadraticBezierTo(at(inner, from).dx, at(inner, from).dy, at(inner + r, from).dx, at(inner + r, from).dy);
} else {
p.lineTo(c.dx, c.dy);
}
return p..close();
}