Skip to content

Commit f009622

Browse files
committed
feat: pie chart rounded corners
1 parent 32e75f5 commit f009622

File tree

3 files changed

+287
-5
lines changed

3 files changed

+287
-5
lines changed

example/ios/Podfile.lock

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ EXTERNAL SOURCES:
2626

2727
SPEC CHECKSUMS:
2828
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
29-
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
30-
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
31-
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
29+
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
30+
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
31+
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
3232

3333
PODFILE CHECKSUM: 0dbd5a87e0ace00c9610d2037ac22083a01f861d
3434

lib/src/chart/pie_chart/pie_chart_data.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ class PieChartSectionData with EquatableMixin {
162162
this.titleStyle,
163163
String? title,
164164
BorderSide? borderSide,
165+
double? cornerRadius,
165166
this.badgeWidget,
166167
double? titlePositionPercentageOffset,
167168
double? badgePositionPercentageOffset,
@@ -171,6 +172,7 @@ class PieChartSectionData with EquatableMixin {
171172
showTitle = showTitle ?? true,
172173
title = title ?? (value == null ? '' : value.toString()),
173174
borderSide = borderSide ?? const BorderSide(width: 0),
175+
cornerRadius = cornerRadius ?? 0.0,
174176
titlePositionPercentageOffset = titlePositionPercentageOffset ?? 0.5,
175177
badgePositionPercentageOffset = badgePositionPercentageOffset ?? 0.5;
176178

@@ -203,6 +205,9 @@ class PieChartSectionData with EquatableMixin {
203205
/// Defines border stroke around the section
204206
final BorderSide borderSide;
205207

208+
/// Defines corner radius for rounded edges (applies to all corners)
209+
final double cornerRadius;
210+
206211
/// Defines a widget that represents the section.
207212
///
208213
/// This can be anything from a text, an image, an animation, and even a combination of widgets.
@@ -234,6 +239,7 @@ class PieChartSectionData with EquatableMixin {
234239
TextStyle? titleStyle,
235240
String? title,
236241
BorderSide? borderSide,
242+
double? cornerRadius,
237243
Widget? badgeWidget,
238244
double? titlePositionPercentageOffset,
239245
double? badgePositionPercentageOffset,
@@ -247,6 +253,7 @@ class PieChartSectionData with EquatableMixin {
247253
titleStyle: titleStyle ?? this.titleStyle,
248254
title: title ?? this.title,
249255
borderSide: borderSide ?? this.borderSide,
256+
cornerRadius: cornerRadius ?? this.cornerRadius,
250257
badgeWidget: badgeWidget ?? this.badgeWidget,
251258
titlePositionPercentageOffset:
252259
titlePositionPercentageOffset ?? this.titlePositionPercentageOffset,
@@ -269,6 +276,7 @@ class PieChartSectionData with EquatableMixin {
269276
titleStyle: TextStyle.lerp(a.titleStyle, b.titleStyle, t),
270277
title: b.title,
271278
borderSide: BorderSide.lerp(a.borderSide, b.borderSide, t),
279+
cornerRadius: lerpDouble(a.cornerRadius, b.cornerRadius, t),
272280
badgeWidget: b.badgeWidget,
273281
titlePositionPercentageOffset: lerpDouble(
274282
a.titlePositionPercentageOffset,
@@ -293,6 +301,7 @@ class PieChartSectionData with EquatableMixin {
293301
titleStyle,
294302
title,
295303
borderSide,
304+
cornerRadius,
296305
badgeWidget,
297306
titlePositionPercentageOffset,
298307
badgePositionPercentageOffset,

lib/src/chart/pie_chart/pie_chart_painter.dart

Lines changed: 275 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,10 @@ class PieChartPainter extends BaseChartPainter<PieChartData> {
217217
final endLineTo = endLineFrom + endLineDirection * section.radius;
218218
final endLine = Line(endLineFrom, endLineTo);
219219

220-
var sectionPath = Path()
220+
var sectionPath = Path();
221+
222+
// First create the basic section path (without rounding)
223+
sectionPath = Path()
221224
..moveTo(startLine.from.dx, startLine.from.dy)
222225
..lineTo(startLine.to.dx, startLine.to.dy)
223226
..arcTo(sectionRadiusRect, startRadians, sweepRadians, false)
@@ -226,7 +229,7 @@ class PieChartPainter extends BaseChartPainter<PieChartData> {
226229
..moveTo(startLine.from.dx, startLine.from.dy)
227230
..close();
228231

229-
/// Subtract section space from the sectionPath
232+
/// First apply section space separators to the basic path
230233
if (sectionSpace != 0) {
231234
final startLineSeparatorPath = createRectPathAroundLine(
232235
Line(startLineFrom, startLineTo),
@@ -257,9 +260,279 @@ class PieChartPainter extends BaseChartPainter<PieChartData> {
257260
}
258261
}
259262

263+
// Then apply border radius to the resulting separated path
264+
if (section.cornerRadius > 0) {
265+
// Get the bounds of the separated path
266+
final pathBounds = sectionPath.getBounds();
267+
if (!pathBounds.isEmpty) {
268+
// We need to calculate new angles for the separated section
269+
// to apply rounding correctly to the actual shape we have
270+
271+
// Calculate effective angles after separation
272+
final separatorAngleReduction = sectionSpace != 0
273+
? math.atan2(sectionSpace, centerRadius + section.radius / 2)
274+
: 0.0;
275+
276+
final effectiveStartRadians = startRadians + separatorAngleReduction;
277+
final effectiveSweepRadians =
278+
sweepRadians - (2 * separatorAngleReduction);
279+
280+
if (effectiveSweepRadians > 0) {
281+
// Create new rects for the adjusted geometry
282+
final effectiveSectionRadiusRect = Rect.fromCircle(
283+
center: center,
284+
radius: centerRadius + section.radius,
285+
);
286+
287+
final effectiveCenterRadiusRect = Rect.fromCircle(
288+
center: center,
289+
radius: centerRadius,
290+
);
291+
292+
// Generate rounded path with the effective angles
293+
sectionPath = generateRoundedSectionPath(
294+
section,
295+
effectiveStartRadians,
296+
effectiveSweepRadians,
297+
center,
298+
centerRadius,
299+
effectiveSectionRadiusRect,
300+
effectiveCenterRadiusRect,
301+
);
302+
}
303+
}
304+
}
305+
260306
return sectionPath;
261307
}
262308

309+
/// Generates a Path for a pie-section with rounded corners.
310+
///
311+
/// This method builds a path that rounds both the outer and inner
312+
/// corners of a pie section (when `centerRadius > 0`). It clamps the
313+
/// requested `section.cornerRadius` separately for the outer and inner
314+
/// edges to avoid geometric overlap when the section is narrow or the
315+
/// radii would be too large for the available arc length.
316+
///
317+
/// Important behaviors / notes:
318+
/// - If `cornerRadius <= 1` the method returns a standard (non-rounded)
319+
/// section path for performance and to avoid tiny visual artifacts.
320+
/// - Outer and inner corner radii are clamped independently (`clampedOuterRadius`
321+
/// and `clampedInnerRadius`) to reasonable maxima based on section size
322+
/// and sweep angle.
323+
/// - The code supports `centerRadius == 0` (fully filled pie) and
324+
/// `centerRadius > 0` (donut). When `centerRadius > 0` the inner
325+
/// corners are rounded as well.
326+
/// - `sectionsSpace` trimming is applied later by subtracting separator
327+
/// rectangles from the resulting path (see `generateSectionPath`).
328+
/// - There are known platform/engine caveats when using `Path.combine` on
329+
/// web-html renderer; the subtraction steps are guarded with try/catch
330+
/// where used.
331+
@visibleForTesting
332+
Path generateRoundedSectionPath(
333+
PieChartSectionData section,
334+
double startRadians,
335+
double sweepRadians,
336+
Offset center,
337+
double centerRadius,
338+
Rect sectionRadiusRect,
339+
Rect centerRadiusRect,
340+
) {
341+
final endRadians = startRadians + sweepRadians;
342+
final outerRadius = centerRadius + section.radius;
343+
// User-provided corner radius (applies uniformly to this section).
344+
final cornerRadius = section.cornerRadius;
345+
346+
final path = Path();
347+
348+
if (cornerRadius <= 1) {
349+
// Si el radio es muy pequeño, usar path normal
350+
final innerStart = center +
351+
Offset(math.cos(startRadians), math.sin(startRadians)) * centerRadius;
352+
final outerStart = center +
353+
Offset(math.cos(startRadians), math.sin(startRadians)) * outerRadius;
354+
final innerEnd = center +
355+
Offset(math.cos(endRadians), math.sin(endRadians)) * centerRadius;
356+
357+
path
358+
..moveTo(innerStart.dx, innerStart.dy)
359+
..lineTo(outerStart.dx, outerStart.dy)
360+
..arcTo(sectionRadiusRect, startRadians, sweepRadians, false)
361+
..lineTo(innerEnd.dx, innerEnd.dy)
362+
..arcTo(centerRadiusRect, endRadians, -sweepRadians, false)
363+
..close();
364+
} else {
365+
// Clamp requested radii to avoid overlaps. We compute a separate
366+
// maximum for the outer arc (based on section radius and sweep angle)
367+
// and for the inner arc (based on centerRadius). This keeps rounding
368+
// visually stable across different section sizes.
369+
final maxRadiusForSection =
370+
math.min(section.radius * 0.3, sweepRadians * outerRadius * 0.15);
371+
final maxRadiusForCenter = centerRadius > 0
372+
? math.min(centerRadius * 0.3, sweepRadians * centerRadius * 0.15)
373+
: 0.0;
374+
final clampedOuterRadius = math.min(cornerRadius, maxRadiusForSection);
375+
final clampedInnerRadius = math.min(cornerRadius, maxRadiusForCenter);
376+
377+
// Compute angular offsets that correspond to the linear corner radii.
378+
// These are used to trim the sweep angles so the rounded joins fit
379+
// cleanly along the arc.
380+
final outerAngleOffset =
381+
outerRadius > 0 ? clampedOuterRadius / outerRadius : 0.0;
382+
final innerAngleOffset =
383+
centerRadius > 0 ? clampedInnerRadius / centerRadius : 0.0;
384+
385+
// Ángulos ajustados para esquinas exteriores
386+
final outerStartAngle = startRadians + outerAngleOffset;
387+
final outerEndAngle = endRadians - outerAngleOffset;
388+
final outerSweepAngle = sweepRadians - (2 * outerAngleOffset);
389+
390+
// Ángulos ajustados para esquinas interiores
391+
final innerStartAngle = startRadians + innerAngleOffset;
392+
final innerEndAngle = endRadians - innerAngleOffset;
393+
final innerSweepAngle = sweepRadians - (2 * innerAngleOffset);
394+
395+
// Puntos de las esquinas exteriores
396+
final outerStartPoint = center +
397+
Offset(math.cos(startRadians), math.sin(startRadians)) * outerRadius;
398+
final outerEndPoint = center +
399+
Offset(math.cos(endRadians), math.sin(endRadians)) * outerRadius;
400+
final outerStartRounded = center +
401+
Offset(math.cos(outerStartAngle), math.sin(outerStartAngle)) *
402+
outerRadius;
403+
final outerEndRounded = center +
404+
Offset(math.cos(outerEndAngle), math.sin(outerEndAngle)) *
405+
outerRadius;
406+
407+
// Puntos de las esquinas interiores
408+
final innerStartPoint = center +
409+
Offset(math.cos(startRadians), math.sin(startRadians)) * centerRadius;
410+
final innerEndPoint = center +
411+
Offset(math.cos(endRadians), math.sin(endRadians)) * centerRadius;
412+
final innerStartRounded = center +
413+
Offset(math.cos(innerStartAngle), math.sin(innerStartAngle)) *
414+
centerRadius;
415+
final innerEndRounded = center +
416+
Offset(math.cos(innerEndAngle), math.sin(innerEndAngle)) *
417+
centerRadius;
418+
419+
// Control points used to connect the rounded corner bezier segments to
420+
// the inner/outer arcs. They lie along the original radial directions
421+
// but offset inward/outward by the clamped radii.
422+
final startOuterControl = center +
423+
Offset(math.cos(startRadians), math.sin(startRadians)) *
424+
(outerRadius - clampedOuterRadius);
425+
final endOuterControl = center +
426+
Offset(math.cos(endRadians), math.sin(endRadians)) *
427+
(outerRadius - clampedOuterRadius);
428+
final startInnerControl = center +
429+
Offset(math.cos(startRadians), math.sin(startRadians)) *
430+
(centerRadius + clampedInnerRadius);
431+
final endInnerControl = center +
432+
Offset(math.cos(endRadians), math.sin(endRadians)) *
433+
(centerRadius + clampedInnerRadius);
434+
435+
// Construir el path
436+
if (centerRadius > 0) {
437+
// Empezar desde la esquina interior redondeada
438+
path.moveTo(innerStartRounded.dx, innerStartRounded.dy);
439+
440+
// Inner starting rounded corner (quadratic join). If the inner
441+
// radius is small we fall back to a straight line to avoid tiny
442+
// bezier segments.
443+
if (clampedInnerRadius > 1) {
444+
path.quadraticBezierTo(
445+
innerStartPoint.dx,
446+
innerStartPoint.dy,
447+
startInnerControl.dx,
448+
startInnerControl.dy,
449+
);
450+
} else {
451+
path.lineTo(innerStartPoint.dx, innerStartPoint.dy);
452+
}
453+
454+
// Línea recta hacia el borde exterior
455+
path.lineTo(startOuterControl.dx, startOuterControl.dy);
456+
457+
// Outer starting rounded corner (quadratic join).
458+
if (clampedOuterRadius > 1) {
459+
path.quadraticBezierTo(
460+
outerStartPoint.dx,
461+
outerStartPoint.dy,
462+
outerStartRounded.dx,
463+
outerStartRounded.dy,
464+
);
465+
} else {
466+
path.lineTo(outerStartPoint.dx, outerStartPoint.dy);
467+
}
468+
} else {
469+
// Si no hay centerRadius, empezar desde el centro
470+
path
471+
..moveTo(center.dx, center.dy)
472+
..lineTo(startOuterControl.dx, startOuterControl.dy);
473+
474+
if (clampedOuterRadius > 1) {
475+
path.quadraticBezierTo(
476+
outerStartPoint.dx,
477+
outerStartPoint.dy,
478+
outerStartRounded.dx,
479+
outerStartRounded.dy,
480+
);
481+
} else {
482+
path.lineTo(outerStartPoint.dx, outerStartPoint.dy);
483+
}
484+
}
485+
486+
// Draw the outer arc between the two rounded outer corner points.
487+
if (outerSweepAngle > 0) {
488+
path.arcTo(sectionRadiusRect, outerStartAngle, outerSweepAngle, false);
489+
}
490+
491+
// Outer ending rounded corner (quadratic join).
492+
if (clampedOuterRadius > 1) {
493+
path
494+
..lineTo(outerEndRounded.dx, outerEndRounded.dy)
495+
..quadraticBezierTo(
496+
outerEndPoint.dx,
497+
outerEndPoint.dy,
498+
endOuterControl.dx,
499+
endOuterControl.dy,
500+
);
501+
} else {
502+
path.lineTo(outerEndPoint.dx, outerEndPoint.dy);
503+
}
504+
505+
if (centerRadius > 0) {
506+
// Línea hacia la esquina interior
507+
path.lineTo(endInnerControl.dx, endInnerControl.dy);
508+
509+
// Inner ending rounded corner (quadratic join).
510+
if (clampedInnerRadius > 1) {
511+
path.quadraticBezierTo(
512+
innerEndPoint.dx,
513+
innerEndPoint.dy,
514+
innerEndRounded.dx,
515+
innerEndRounded.dy,
516+
);
517+
} else {
518+
path.lineTo(innerEndPoint.dx, innerEndPoint.dy);
519+
}
520+
521+
// Arco interior
522+
if (innerSweepAngle > 0) {
523+
path.arcTo(centerRadiusRect, innerEndAngle, -innerSweepAngle, false);
524+
}
525+
} else {
526+
// Si no hay centerRadius, cerrar hacia el centro
527+
path.lineTo(center.dx, center.dy);
528+
}
529+
530+
path.close();
531+
}
532+
533+
return path;
534+
}
535+
263536
/// Creates a rect around a narrow line
264537
@visibleForTesting
265538
Path createRectPathAroundLine(Line line, double width) {

0 commit comments

Comments
 (0)