diff --git a/example/lib/presentation/samples/gauge/gauge_chart_sample1.dart b/example/lib/presentation/samples/gauge/gauge_chart_sample1.dart index 99436ca5e..dcc293942 100644 --- a/example/lib/presentation/samples/gauge/gauge_chart_sample1.dart +++ b/example/lib/presentation/samples/gauge/gauge_chart_sample1.dart @@ -1,6 +1,3 @@ -import 'dart:math'; -import 'dart:ui'; - import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart_app/presentation/resources/app_resources.dart'; import 'package:flutter/material.dart'; @@ -15,6 +12,12 @@ class GaugeChartSample1 extends StatefulWidget { class GaugeChartSample1State extends State { double _value = 0.5; + // Continuous min/max thresholds — deliberately off the 11-tick grid + // (0.0, 0.1, ..., 1.0) to show GaugeMarker carries an arbitrary + // value, not a discrete tick index. + static const _minValue = 0.23; + static const _maxValue = 0.77; + @override Widget build(BuildContext context) { return Padding( @@ -40,16 +43,46 @@ class GaugeChartSample1State extends State { position: GaugeTickPosition.center, count: 11, offset: 0, - painter: _MyCustomGaugeMinMaxTickPainter( + painter: GaugeTickCirclePainter( color: AppColors.contentColorWhite, radius: 5, - minIndex: 2, - maxIndex: 8, - minMaxLineLength: 40, - minMaxLineStrokeWidth: 4, ), checkToShowTick: GaugeTicks.hideEndpoints, ), + markers: const [ + GaugeMarker( + value: _minValue, + position: GaugeTickPosition.center, + painter: GaugeMarkerLinePainter( + length: 40, + thickness: 4, + color: Colors.green, + label: 'Min', + labelStyle: TextStyle( + color: Colors.green, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + labelSide: GaugeMarkerLabelSide.inward, + ), + ), + GaugeMarker( + value: _maxValue, + position: GaugeTickPosition.center, + painter: GaugeMarkerLinePainter( + length: 40, + thickness: 4, + color: Colors.red, + label: 'Max', + labelStyle: TextStyle( + color: Colors.red, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + labelSide: GaugeMarkerLabelSide.inward, + ), + ), + ], pointers: [ // Needle extending from center toward the current value. GaugePointer( @@ -95,97 +128,3 @@ class GaugeChartSample1State extends State { ); } } - -class _MyCustomGaugeMinMaxTickPainter extends GaugeTickCirclePainter { - _MyCustomGaugeMinMaxTickPainter({ - super.color, - super.radius, - required this.minIndex, - required this.maxIndex, - required this.minMaxLineLength, - required this.minMaxLineStrokeWidth, - this.textOffset = 6, - this.minTextStyle = const TextStyle( - color: Colors.green, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - this.maxTextStyle = const TextStyle( - color: Colors.red, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - }) : super(); - - final int minIndex; - final int maxIndex; - final double minMaxLineLength; - final double minMaxLineStrokeWidth; - final double textOffset; - final TextStyle minTextStyle; - final TextStyle maxTextStyle; - - final _paint = Paint(); - - @override - void draw(Canvas canvas, GaugeTickInfo tickInfo) { - super.draw(canvas, tickInfo); - - if (tickInfo.index != minIndex && tickInfo.index != maxIndex) { - return; - } - final isMin = tickInfo.index == minIndex; - final text = isMin ? "Min" : "Max"; - final textStyle = isMin ? minTextStyle : maxTextStyle; - final rotation = isMin ? pi : 0.0; - final textPainter = TextPainter( - text: TextSpan(text: text, style: textStyle), - textDirection: TextDirection.ltr, - )..layout(); - // 180 degrees - canvas.rotate(rotation); - final offsetX = isMin - ? 0 + textPainter.width + textOffset - : 0 - textPainter.width - minMaxLineLength / 2 - textOffset; - textPainter.paint(canvas, Offset(offsetX, 0 - textPainter.height / 2)); - _paint.color = textStyle.color!; - _paint.strokeWidth = minMaxLineStrokeWidth; - canvas.drawLine( - Offset(0 - minMaxLineLength / 2, 0), - Offset(0 + minMaxLineLength / 2, 0), - _paint, - ); - } - - @override - GaugeTickPainter lerp(GaugeTickPainter b, double t) { - if (b is! _MyCustomGaugeMinMaxTickPainter) { - return b; - } - return _MyCustomGaugeMinMaxTickPainter( - color: Color.lerp(color, b.color, t)!, - radius: lerpDouble(radius, b.radius, t)!, - minIndex: minIndex, - maxIndex: maxIndex, - minMaxLineLength: lerpDouble(minMaxLineLength, b.minMaxLineLength, t)!, - minMaxLineStrokeWidth: - lerpDouble(minMaxLineStrokeWidth, b.minMaxLineStrokeWidth, t)!, - textOffset: lerpDouble(textOffset, b.textOffset, t)!, - minTextStyle: TextStyle.lerp(minTextStyle, b.minTextStyle, t)!, - maxTextStyle: TextStyle.lerp(maxTextStyle, b.maxTextStyle, t)!, - ); - } - - @override - List get props => - [ - super.props, - minIndex, - maxIndex, - minMaxLineLength, - minMaxLineStrokeWidth, - textOffset, - minTextStyle, - maxTextStyle, - ]; -} diff --git a/lib/src/chart/gauge_chart/gauge_chart_data.dart b/lib/src/chart/gauge_chart/gauge_chart_data.dart index f100a96d7..79e3bfae0 100644 --- a/lib/src/chart/gauge_chart/gauge_chart_data.dart +++ b/lib/src/chart/gauge_chart/gauge_chart_data.dart @@ -1,3 +1,4 @@ +import 'dart:math' as math; import 'dart:ui'; import 'package:equatable/equatable.dart'; @@ -30,10 +31,12 @@ class GaugeChartData extends BaseChartData with EquatableMixin { this.ringsSpace = 0.0, this.ticks, List pointers = const [], + List markers = const [], GaugeTouchData? touchData, super.borderData, }) : rings = List.unmodifiable(rings), pointers = List.unmodifiable(pointers), + markers = List.unmodifiable(markers), gaugeTouchData = touchData ?? GaugeTouchData(), assert(maxValue > minValue, 'maxValue must be greater than minValue'), assert( @@ -81,6 +84,7 @@ class GaugeChartData extends BaseChartData with EquatableMixin { GaugeDirection direction = GaugeDirection.clockwise, GaugeTicks? ticks, List pointers = const [], + List markers = const [], GaugeTouchData? touchData, FlBorderData? borderData, }) => @@ -102,6 +106,7 @@ class GaugeChartData extends BaseChartData with EquatableMixin { direction: direction, ticks: ticks, pointers: pointers, + markers: markers, touchData: touchData, borderData: borderData, ); @@ -147,6 +152,14 @@ class GaugeChartData extends BaseChartData with EquatableMixin { /// by default. final List pointers; + /// Markers anchored at arbitrary continuous values along the gauge's + /// scale. Like [ticks] in shape and local frame — but each marker + /// carries its own `value` on `[minValue, maxValue]` instead of being + /// constrained to an evenly-spaced index. Use this to highlight + /// thresholds, targets, or annotations at non-grid positions. Empty + /// by default. + final List markers; + /// Touch configuration and callback. final GaugeTouchData gaugeTouchData; @@ -166,6 +179,7 @@ class GaugeChartData extends BaseChartData with EquatableMixin { ringsSpace, ticks, pointers, + markers, gaugeTouchData, borderData, ]; @@ -181,6 +195,7 @@ class GaugeChartData extends BaseChartData with EquatableMixin { double? ringsSpace, GaugeTicks? ticks, List? pointers, + List? markers, GaugeTouchData? touchData, FlBorderData? borderData, }) => @@ -195,6 +210,7 @@ class GaugeChartData extends BaseChartData with EquatableMixin { ringsSpace: ringsSpace ?? this.ringsSpace, ticks: ticks ?? this.ticks, pointers: pointers ?? this.pointers, + markers: markers ?? this.markers, touchData: touchData ?? gaugeTouchData, borderData: borderData ?? this.borderData, ); @@ -215,6 +231,7 @@ class GaugeChartData extends BaseChartData with EquatableMixin { ringsSpace: lerpDouble(a.ringsSpace, b.ringsSpace, t)!, ticks: GaugeTicks.lerp(a.ticks, b.ticks, t), pointers: lerpGaugePointerList(a.pointers, b.pointers, t)!, + markers: lerpGaugeMarkerList(a.markers, b.markers, t)!, touchData: b.gaugeTouchData, borderData: FlBorderData.lerp(a.borderData, b.borderData, t), ); @@ -941,6 +958,316 @@ class GaugePointerCirclePainter extends GaugePointerPainter { [radius, anchorRadius, color, strokeWidth, strokeColor]; } +/// A single marker drawn along the gauge's arc at an arbitrary +/// continuous [value]. Conceptually a continuous-value cousin of +/// [GaugeTicks] — markers reference the ring stack as a whole (outer / +/// inner / center) and use the same arc-anchored local frame as +/// [GaugeTickPainter], but each carries its own `value` instead of +/// being constrained to an evenly-spaced index. +/// +/// Use this to highlight thresholds, targets, or annotations that fall +/// at non-grid positions (e.g. a "min" marker at `0.234` or a "target" +/// at `0.71`). For drawing things that emanate from the gauge's center +/// (needles, hands), use [GaugePointer] instead. +@immutable +class GaugeMarker with EquatableMixin { + const GaugeMarker({ + required this.value, + this.position = GaugeTickPosition.outer, + this.offset = 0, + this.painter = const GaugeMarkerLinePainter(), + }); + + /// Position on the gauge scale where the marker is anchored. Values + /// outside `[GaugeChartData.minValue, maxValue]` are not asserted — + /// the marker simply extends past the sweep endpoints. + final double value; + + /// Where the marker sits relative to the gauge's outer / inner + /// bounds. Same semantics as [GaugeTicks.position]. + final GaugeTickPosition position; + + /// Signed pixel delta from the natural marker anchor, measured along + /// the [position]'s outward axis. Same semantics as + /// [GaugeTicks.offset]. + final double offset; + + /// Renders the marker. The canvas is pre-translated and pre-rotated + /// so the painter just draws a horizontal, right-facing shape at + /// the origin — see [GaugeMarkerPainter.draw]. + final GaugeMarkerPainter painter; + + GaugeMarker copyWith({ + double? value, + GaugeTickPosition? position, + double? offset, + GaugeMarkerPainter? painter, + }) => + GaugeMarker( + value: value ?? this.value, + position: position ?? this.position, + offset: offset ?? this.offset, + painter: painter ?? this.painter, + ); + + static GaugeMarker lerp(GaugeMarker a, GaugeMarker b, double t) => + GaugeMarker( + value: lerpDouble(a.value, b.value, t)!, + position: b.position, + offset: lerpDouble(a.offset, b.offset, t)!, + painter: a.painter.lerp(b.painter, t), + ); + + @override + List get props => [value, position, offset, painter]; +} + +/// Context passed to [GaugeMarkerPainter.draw]. +@immutable +class GaugeMarkerInfo with EquatableMixin { + const GaugeMarkerInfo({ + required this.value, + required this.minValue, + required this.maxValue, + required this.angleDegrees, + }); + + /// Scale value at this marker's position, on `[minValue, maxValue]`. + final double value; + + /// The gauge's minimum scale value ([GaugeChartData.minValue]). + final double minValue; + + /// The gauge's maximum scale value ([GaugeChartData.maxValue]). + final double maxValue; + + /// Angle on the gauge's coordinate system at which this marker is + /// anchored, in degrees (same convention as + /// [GaugeChartData.startDegreeOffset]: `0°` points right, increases + /// clockwise; values may be negative or beyond `360`). Useful for + /// angle-aware rendering — e.g. flipping a label by 180° on the + /// gauge's left hemisphere so it stays upright. + final double angleDegrees; + + @override + List get props => [value, minValue, maxValue, angleDegrees]; +} + +/// Interface for rendering a single [GaugeMarker]. +/// +/// Mirrors the [GaugeTickPainter] local-frame convention exactly: +/// +/// - origin `(0, 0)` is the **marker's anchor on the arc** +/// - `+x` axis points in the marker's outward direction (per +/// [GaugeMarker.position]) +/// - `+y` axis is **tangent** to the arc in the sweep direction +/// +/// Draw a horizontal, right-facing shape at the origin and the gauge +/// handles placing and rotating it for every marker's value. +abstract class GaugeMarkerPainter with EquatableMixin { + const GaugeMarkerPainter(); + + /// Draws a single marker in the pre-transformed local frame + /// described in the class docstring. + void draw(Canvas canvas, GaugeMarkerInfo markerInfo); + + /// Bounding box of the marker in the unrotated local frame. + /// + /// `width` is the radial extent (along `+x`) and is used to reserve + /// outer padding when [GaugeMarker.position] is + /// [GaugeTickPosition.outer]; `height` is the tangential extent + /// (across `+y`). + Size getSize(); + + /// Lerps two painter configurations. Cross-type lerps fall back to + /// [b], matching [GaugeTickPainter.lerp] / [FlDotPainter.lerp]. + GaugeMarkerPainter lerp(GaugeMarkerPainter b, double t); +} + +/// Where the line sits relative to the marker's anchor on the arc. +enum GaugeMarkerLineAlignment { + /// Line spans `[-length / 2, length / 2]` along `+x` — a symmetric + /// crossbar centered on the marker's anchor. Reads as a "this point + /// on the arc" indicator. Default for markers. + centered, + + /// Line spans `[0, length]` along `+x` — anchored at the marker's + /// anchor and extending radially outward (tick-style, mirrors + /// [GaugeTickLinePainter]). + outward, +} + +/// Which side of the line a [GaugeMarkerLinePainter]'s optional label +/// sits on, in the painter's pre-transformed local frame. +enum GaugeMarkerLabelSide { + /// Label sits radially outward of the line — further from the gauge + /// center. Default. + outward, + + /// Label sits radially inward of the line — between the line and the + /// gauge's center. + inward, +} + +/// [GaugeMarkerPainter] that draws a line segment, optionally with a +/// text label beside it. +/// +/// The line is centered on the marker's anchor by default +/// ([GaugeMarkerLineAlignment.centered]) — markers are point +/// indicators, so a symmetric crossbar reads more naturally than a +/// tick-style outward line. Switch to +/// [GaugeMarkerLineAlignment.outward] for tick-style behavior. +/// +/// When [label] is non-null and non-empty, the painter renders the +/// text on [labelSide] of the line, [labelOffset] pixels away. The +/// label is **auto-flipped by 180°** when the marker sits on the +/// gauge's left hemisphere (`cos(angleDegrees) < 0`) so it stays +/// upright regardless of where the marker lands on the dial — no +/// per-marker flip flag needed. +/// +/// Note: [getSize] reports only the line's outward radial extent. If +/// you place a long label on [GaugeMarkerLabelSide.outward] and want +/// the rings to shrink to make room, add `offset: +/// labelOffset + estimatedLabelWidth` on the [GaugeMarker] itself. +class GaugeMarkerLinePainter extends GaugeMarkerPainter { + const GaugeMarkerLinePainter({ + this.length = 10.0, + this.thickness = 2.0, + this.color = const Color(0xFF000000), + this.strokeCap = StrokeCap.round, + this.alignment = GaugeMarkerLineAlignment.centered, + this.label, + this.labelStyle, + this.labelOffset = 6.0, + this.labelSide = GaugeMarkerLabelSide.outward, + }) : assert(length > 0, 'length must be > 0'), + assert(thickness > 0, 'thickness must be > 0'), + assert(labelOffset >= 0, 'labelOffset must be >= 0'); + + final double length; + final double thickness; + final Color color; + final StrokeCap strokeCap; + + /// Where the line sits relative to the marker's anchor. + final GaugeMarkerLineAlignment alignment; + + /// Optional text drawn alongside the line. Skipped when null, empty, + /// or [labelStyle] is null. + final String? label; + + /// Style applied to [label]. If null, no label is drawn even when + /// [label] is set. + final TextStyle? labelStyle; + + /// Gap in pixels between the line's end and the label. Measured + /// along the radial axis on [labelSide]. + final double labelOffset; + + /// Side of the line on which the label sits. + final GaugeMarkerLabelSide labelSide; + + double get _lineStartX => switch (alignment) { + GaugeMarkerLineAlignment.centered => -length / 2, + GaugeMarkerLineAlignment.outward => 0, + }; + + double get _lineEndX => switch (alignment) { + GaugeMarkerLineAlignment.centered => length / 2, + GaugeMarkerLineAlignment.outward => length, + }; + + @override + void draw(Canvas canvas, GaugeMarkerInfo markerInfo) { + canvas.drawLine( + Offset(_lineStartX, 0), + Offset(_lineEndX, 0), + Paint() + ..color = color + ..strokeWidth = thickness + ..strokeCap = strokeCap + ..isAntiAlias = true, + ); + + final label = this.label; + final style = labelStyle; + if (label == null || label.isEmpty || style == null) return; + + final textPainter = TextPainter( + text: TextSpan(text: label, style: style), + textDirection: TextDirection.ltr, + )..layout(); + + // Label position in the painter's logical (un-flipped) frame. + final labelLeftX = switch (labelSide) { + GaugeMarkerLabelSide.outward => _lineEndX + labelOffset, + GaugeMarkerLabelSide.inward => + _lineStartX - labelOffset - textPainter.width, + }; + + // Auto-flip the label by 180° when the marker sits on the gauge's + // left hemisphere so its glyphs stay upright. We rotate around + // the label's own center so the *position* (which side of the + // line it sits on) doesn't change — only its orientation. + final angleRad = markerInfo.angleDegrees * math.pi / 180.0; + final flipText = math.cos(angleRad) < 0; + if (flipText) { + final centerX = labelLeftX + textPainter.width / 2; + canvas + ..save() + ..translate(centerX, 0) + ..rotate(math.pi); + textPainter.paint( + canvas, + Offset(-textPainter.width / 2, -textPainter.height / 2), + ); + canvas.restore(); + } else { + textPainter.paint( + canvas, + Offset(labelLeftX, -textPainter.height / 2), + ); + } + } + + /// Outward radial extent of the line, used to reserve outer padding + /// on the [GaugeChartData]. Does not include the label's extent — + /// see the class docstring for guidance on outward labels. + @override + Size getSize() => Size(_lineEndX, thickness); + + @override + GaugeMarkerPainter lerp(GaugeMarkerPainter b, double t) { + if (b is! GaugeMarkerLinePainter) { + return b; + } + return GaugeMarkerLinePainter( + length: lerpDouble(length, b.length, t)!, + thickness: lerpDouble(thickness, b.thickness, t)!, + color: Color.lerp(color, b.color, t)!, + strokeCap: b.strokeCap, + alignment: b.alignment, + label: b.label, + labelStyle: TextStyle.lerp(labelStyle, b.labelStyle, t), + labelOffset: lerpDouble(labelOffset, b.labelOffset, t)!, + labelSide: b.labelSide, + ); + } + + @override + List get props => [ + length, + thickness, + color, + strokeCap, + alignment, + label, + labelStyle, + labelOffset, + labelSide, + ]; +} + class GaugeTouchData extends FlTouchData with EquatableMixin { GaugeTouchData({ diff --git a/lib/src/chart/gauge_chart/gauge_chart_painter.dart b/lib/src/chart/gauge_chart/gauge_chart_painter.dart index 68df06ee1..e96889904 100644 --- a/lib/src/chart/gauge_chart/gauge_chart_painter.dart +++ b/lib/src/chart/gauge_chart/gauge_chart_painter.dart @@ -25,6 +25,7 @@ class GaugeChartPainter extends BaseChartPainter { super.paint(context, canvasWrapper, holder); drawSections(canvasWrapper, holder); drawTicks(context, canvasWrapper, holder); + drawMarkers(canvasWrapper, holder); drawPointers(canvasWrapper, holder); } @@ -318,29 +319,40 @@ class GaugeChartPainter extends BaseChartPainter { ); } - /// Padding reserved past the widget's shortest side for - /// outer-positioned ticks (and their labels, when present). + /// Padding reserved past the widget's shortest side for any overlay + /// (ticks or markers) anchored at [GaugeTickPosition.outer]. /// - /// Accounts for the tick's radial extent (`painter.getSize().width` - /// because the canvas is rotated so `+x` is radial), the signed - /// [GaugeTicks.offset], and — when [GaugeTicks.labelBuilder] is set - /// — a label-height heuristic (`labelStyle.fontSize * 1.2`, falling - /// back to 14) plus [GaugeTicks.labelOffset]. - double _outerTickPadding(GaugeChartData data) { + /// Returns the max outward extent across all outer overlays — they + /// sit at different angles, so the rings only need to shrink by the + /// largest one. For ticks: `painter.getSize().width + offset` plus a + /// label-height heuristic (`labelStyle.fontSize * 1.2`, falling back + /// to 14) and `labelOffset` when [GaugeTicks.labelBuilder] is set. + /// For each marker: `painter.getSize().width + offset`. + double _outerOverlayPadding(GaugeChartData data) { + var padding = 0.0; final ticks = data.ticks; - if (ticks == null || ticks.position != GaugeTickPosition.outer) return 0; - var padding = ticks.offset + ticks.painter.getSize().width; - if (ticks.labelBuilder != null) { - final labelHeight = (ticks.labelStyle?.fontSize ?? 14) * 1.2; - padding += ticks.labelOffset + labelHeight; + if (ticks != null && ticks.position == GaugeTickPosition.outer) { + var p = ticks.offset + ticks.painter.getSize().width; + if (ticks.labelBuilder != null) { + final labelHeight = (ticks.labelStyle?.fontSize ?? 14) * 1.2; + p += ticks.labelOffset + labelHeight; + } + padding = math.max(padding, p); + } + for (final marker in data.markers) { + if (marker.position != GaugeTickPosition.outer) continue; + padding = math.max( + padding, + marker.offset + marker.painter.getSize().width, + ); } return math.max(0, padding); } /// Outer radius available for the gauge's rings — shrunk by - /// [_outerTickPadding] when outer ticks are configured. + /// [_outerOverlayPadding] when outer ticks or markers are configured. double _outerArcRadius(Size viewSize, GaugeChartData data) => - viewSize.shortestSide / 2 - _outerTickPadding(data); + viewSize.shortestSide / 2 - _outerOverlayPadding(data); /// Total radial thickness consumed by all rings plus the gaps between /// them. @@ -369,16 +381,21 @@ class GaugeChartPainter extends BaseChartPainter { return centers; } - /// Radial position of the tick's anchor point (where the canvas - /// origin sits before the painter's `draw()` is called). - double _tickAnchorRadius(Size viewSize, GaugeChartData data) { - final ticks = data.ticks!; + /// Radial position of an overlay's anchor point (where the canvas + /// origin sits before the painter's `draw()` is called) — shared by + /// ticks and markers. + double _overlayAnchorRadius( + Size viewSize, + GaugeChartData data, + GaugeTickPosition position, + double offset, + ) { final outer = _outerArcRadius(viewSize, data); final inner = outer - _totalRingsDepth(data); - return switch (ticks.position) { - GaugeTickPosition.outer => outer + ticks.offset, - GaugeTickPosition.inner => inner - ticks.offset, - GaugeTickPosition.center => (outer + inner) / 2 + ticks.offset, + return switch (position) { + GaugeTickPosition.outer => outer + offset, + GaugeTickPosition.inner => inner - offset, + GaugeTickPosition.center => (outer + inner) / 2 + offset, }; } @@ -399,7 +416,8 @@ class GaugeChartPainter extends BaseChartPainter { final viewSize = canvasWrapper.size; final c = center(viewSize); final dir = data.direction == GaugeDirection.clockwise ? 1 : -1; - final tickAnchorRadius = _tickAnchorRadius(viewSize, data); + final tickAnchorRadius = + _overlayAnchorRadius(viewSize, data, ticks.position, ticks.offset); final outwardSign = ticks.position == GaugeTickPosition.inner ? -1.0 : 1.0; final rotationOffset = ticks.position == GaugeTickPosition.inner ? math.pi : 0.0; @@ -459,6 +477,60 @@ class GaugeChartPainter extends BaseChartPainter { } } + /// Draws every [GaugeMarker]. For each marker the canvas is + /// translated to the marker's anchor on the arc and rotated so the + /// painter just draws a horizontal, right-facing shape at the + /// origin — same local frame as [GaugeTickPainter.draw]. + /// + /// Drawn after ticks and before pointers, so a marker overlays + /// neighbouring tick marks but stays under any [GaugePointer]. + @visibleForTesting + void drawMarkers( + CanvasWrapper canvasWrapper, + PaintHolder holder, + ) { + final data = holder.data; + if (data.markers.isEmpty) return; + + final viewSize = canvasWrapper.size; + final c = center(viewSize); + final dir = data.direction == GaugeDirection.clockwise ? 1 : -1; + final range = data.maxValue - data.minValue; + + for (final marker in data.markers) { + final progress = (marker.value - data.minValue) / range; + final angleDeg = + data.startDegreeOffset + dir * data.sweepAngle * progress; + final angleRad = Utils().radians(angleDeg); + final anchorRadius = _overlayAnchorRadius( + viewSize, + data, + marker.position, + marker.offset, + ); + final rotationOffset = + marker.position == GaugeTickPosition.inner ? math.pi : 0.0; + final anchor = Offset( + c.dx + anchorRadius * math.cos(angleRad), + c.dy + anchorRadius * math.sin(angleRad), + ); + canvasWrapper + ..save() + ..translate(anchor.dx, anchor.dy) + ..rotate(angleRad + rotationOffset); + marker.painter.draw( + canvasWrapper.canvas, + GaugeMarkerInfo( + value: marker.value, + minValue: data.minValue, + maxValue: data.maxValue, + angleDegrees: angleDeg, + ), + ); + canvasWrapper.restore(); + } + } + double _signedSweep(GaugeChartData data) => data.direction == GaugeDirection.clockwise ? data.sweepAngle diff --git a/lib/src/utils/lerp.dart b/lib/src/utils/lerp.dart index 7958910e6..b4e106ba6 100644 --- a/lib/src/utils/lerp.dart +++ b/lib/src/utils/lerp.dart @@ -167,6 +167,14 @@ List? lerpGaugePointerList( ) => lerpList(a, b, t, lerp: GaugePointer.lerp); +/// Lerps [GaugeMarker] list based on [t] value, check [Tween.lerp]. +List? lerpGaugeMarkerList( + List? a, + List? b, + double t, +) => + lerpList(a, b, t, lerp: GaugeMarker.lerp); + /// Lerps [ScatterSpot] list based on [t] value, check [Tween.lerp]. List? lerpScatterSpotList( List? a, diff --git a/repo_files/documentations/gauge_chart.md b/repo_files/documentations/gauge_chart.md index e782d4c16..d5af84714 100644 --- a/repo_files/documentations/gauge_chart.md +++ b/repo_files/documentations/gauge_chart.md @@ -118,6 +118,7 @@ GaugeChart( |ringsSpace| radial pixel gap between adjacent rings|0.0| |ticks| optional tick configuration; see [GaugeTicks](#GaugeTicks)|null| |pointers| list of [GaugePointer](#GaugePointer)s drawn on top of the rings and ticks. Empty by default; each pointer carries its own `value` on the shared scale|`[]`| +|markers| list of [GaugeMarker](#GaugeMarker)s anchored at arbitrary continuous values along the gauge's scale. Empty by default|`[]`| |touchData| [GaugeTouchData](#GaugeTouchData) holds the touch interactivity details|GaugeTouchData()| |borderData| shows a border around the chart, see [FlBorderData](base_chart.md#FlBorderData)|FlBorderData()| @@ -238,6 +239,71 @@ pointers: const [ ], ``` +### GaugeMarker +A marker anchored at an arbitrary continuous value along the gauge's scale. Conceptually a continuous-value cousin of [GaugeTicks](#GaugeTicks): markers reference the ring stack as a whole (outer / inner / center) and use the same arc-anchored local frame as [GaugeTickPainter](#GaugeTickPainter), but each carries its own `value` instead of being constrained to an evenly-spaced index. + +Use markers for thresholds, targets, or annotations that fall at non-grid positions (e.g. a "min" marker at `0.234` or a "target" at `0.71`). For things that emanate from the gauge's center (needles, hands), use [GaugePointer](#GaugePointer) instead. + +|PropName|Description|default value| +|:-------|:----------|:------------| +|value| position on the gauge scale where the marker is anchored. Values outside `[minValue, maxValue]` simply extend past the sweep endpoints|required| +|position| where the marker sits relative to the gauge's outer / inner bounds — same semantics as [GaugeTickPosition](#GaugeTickPosition)|GaugeTickPosition.outer| +|offset| signed pixel delta from the natural marker anchor, along the position's outward axis — same semantics as `GaugeTicks.offset`|0| +|painter| [GaugeMarkerPainter](#GaugeMarkerPainter) that renders the marker's shape|GaugeMarkerLinePainter()| + +When `position: outer`, the gauge reserves padding so the rings shrink to keep the marker on canvas (the same way it does for outer ticks). The reserved padding is the **max** outward extent across all outer overlays, since they sit at different angles. + +```dart +markers: [ + GaugeMarker( + value: 0.234, + position: GaugeTickPosition.center, + painter: GaugeMarkerLinePainter( + length: 40, + thickness: 4, + color: Colors.green, + label: 'Min', + labelStyle: TextStyle(color: Colors.green, fontWeight: FontWeight.bold), + labelSide: GaugeMarkerLabelSide.inward, + ), + ), + GaugeMarker( + value: 0.71, + position: GaugeTickPosition.center, + painter: GaugeMarkerLinePainter( + length: 40, + thickness: 4, + color: Colors.red, + label: 'Max', + labelStyle: TextStyle(color: Colors.red, fontWeight: FontWeight.bold), + labelSide: GaugeMarkerLabelSide.inward, + ), + ), +], +``` + +### GaugeMarkerPainter +Interface for rendering a single [GaugeMarker](#GaugeMarker). Subclass it to draw custom marker shapes. Mirrors the [GaugeTickPainter](#GaugeTickPainter) pattern — the local frame is identical, so visually-similar tick painters can be ported in a couple of lines. + +**Pre-transformed canvas** — the gauge painter translates and rotates the canvas around each marker's anchor on the arc before calling `draw`, so implementations never touch trigonometry: + +- origin `(0, 0)` is the **marker's anchor on the arc** +- `+x` axis points in the marker's outward direction (per `GaugeMarker.position`) +- `+y` axis is **tangent** to the arc in the sweep direction + +Draw a horizontal, right-facing shape at the origin; the gauge handles placing and rotating it for every marker's value. `getSize()` reports the bounding box in this unrotated local frame — `width` is the radial extent (used for outer-position padding), `height` is the tangential extent. + +Built-in implementations: +- **GaugeMarkerLinePainter** — line segment, optionally with a text label beside it. Centered on the marker's anchor by default (markers are point indicators, so a symmetric crossbar reads more naturally than a tick-style outward line). This is the default. Properties: + - `length` (default 10), `thickness` (default 2), `color` (default black), `strokeCap` (default `StrokeCap.round`). + - `alignment` — `GaugeMarkerLineAlignment.centered` (default) draws the line spanning `[-length/2, +length/2]`; `.outward` matches the tick-style line `[0, length]`. + - `label` (optional) — text drawn alongside the line. When set together with `labelStyle`, the painter renders the text on `labelSide` of the line, `labelOffset` pixels away. The label is **auto-flipped by 180°** when the marker sits on the gauge's left hemisphere (`cos(angleDegrees) < 0`) so it stays upright regardless of where it lands on the dial — no per-marker flip flag needed. + - `labelStyle` — applied to the label; required for the label to render. + - `labelOffset` (default 6) — gap in pixels between the line's end and the label. + - `labelSide` — `GaugeMarkerLabelSide.outward` (default) sits the label radially outward of the line; `.inward` sits it between the line and the gauge center. + +`getSize()` reports only the line's outward extent. If you place a long label on `GaugeMarkerLabelSide.outward` and want the rings to shrink to make room, add `offset: labelOffset + estimatedLabelWidth` on the [GaugeMarker](#GaugeMarker) itself. + ### GaugeTouchData ([read about touch handling](handle_touches.md)) |PropName|Description|default value| |:-------|:----------|:------------| diff --git a/test/chart/gauge_chart/gauge_chart_data_test.dart b/test/chart/gauge_chart/gauge_chart_data_test.dart index e751f4655..73174877b 100644 --- a/test/chart/gauge_chart/gauge_chart_data_test.dart +++ b/test/chart/gauge_chart/gauge_chart_data_test.dart @@ -1,3 +1,6 @@ +import 'dart:math'; +import 'dart:ui'; + import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -662,6 +665,249 @@ void main() { expect(a.props.length, 5); }); + test('GaugeMarker equality, copyWith, lerp', () { + const a = GaugeMarker( + value: 0.2, + offset: 4, + ); + const b = GaugeMarker( + value: 0.8, + offset: 12, + position: GaugeTickPosition.inner, + painter: _DummyMarkerPainter(), + ); + expect(a == a.copyWith(), true); + expect(a == a.copyWith(value: 0.5), false); + expect(a == a.copyWith(offset: 5), false); + expect( + a == a.copyWith(position: GaugeTickPosition.inner), + false, + ); + expect( + a == + a.copyWith( + painter: const GaugeMarkerLinePainter(length: 99), + ), + false, + ); + + final mid = GaugeMarker.lerp(a, b, 0.5); + expect(mid.value, closeTo(0.5, 1e-9)); + expect(mid.offset, closeTo(8, 1e-9)); + // Position snaps to b (matches GaugeTicks.lerp pattern). + expect(mid.position, GaugeTickPosition.inner); + // Cross-type painter lerp snaps to b. + expect(mid.painter, isA<_DummyMarkerPainter>()); + }); + + test('GaugeMarkerInfo equality and props', () { + const a = GaugeMarkerInfo( + value: 0.5, + minValue: 0, + maxValue: 1, + angleDegrees: 0, + ); + const b = GaugeMarkerInfo( + value: 0.5, + minValue: 0, + maxValue: 1, + angleDegrees: 0, + ); + const c = GaugeMarkerInfo( + value: 0.6, + minValue: 0, + maxValue: 1, + angleDegrees: 0, + ); + const d = GaugeMarkerInfo( + value: 0.5, + minValue: 0, + maxValue: 1, + angleDegrees: 45, + ); + expect(a == b, true); + expect(a == c, false); + expect(a == d, false); + expect(a.props.length, 4); + }); + + test('GaugeMarkerLinePainter equality, getSize, lerp', () { + const a = GaugeMarkerLinePainter(); + const b = GaugeMarkerLinePainter(); + const c = GaugeMarkerLinePainter(length: 30); + expect(a == b, true); + expect(a == c, false); + // Default alignment is centered → outward extent is length / 2. + expect(a.getSize(), const Size(5, 2)); + // Outward alignment → outward extent is length. + expect( + const GaugeMarkerLinePainter( + alignment: GaugeMarkerLineAlignment.outward, + ).getSize(), + const Size(10, 2), + ); + + // Same-type lerp blends scalar fields, snaps strokeCap / + // alignment / label* to b. + final mid = a.lerp(c, 0.5) as GaugeMarkerLinePainter; + expect(mid.length, closeTo(20, 1e-9)); + + // alignment, label, labelStyle, labelOffset, labelSide all + // affect equality. + const otherAlignment = + GaugeMarkerLinePainter(alignment: GaugeMarkerLineAlignment.outward); + expect(a == otherAlignment, false); + const labeled = + GaugeMarkerLinePainter(label: 'x', labelStyle: TextStyle()); + expect(a == labeled, false); + + // Cross-type lerp snaps to b. + final fallback = a.lerp(const _DummyMarkerPainter(), 0.3); + expect(fallback, isA<_DummyMarkerPainter>()); + }); + + test('GaugeMarkerLinePainter draw — centered default + outward', () { + // Default: centered → line spans [-length/2, length/2]. + const centered = GaugeMarkerLinePainter(); + final canvas = _RecordingCanvas(); + centered.draw(canvas, _markerInfo); + expect(canvas.lines.length, 1); + expect(canvas.lines.first.p1, const Offset(-5, 0)); + expect(canvas.lines.first.p2, const Offset(5, 0)); + + // alignment: outward → line spans [0, length]. + const outward = GaugeMarkerLinePainter( + alignment: GaugeMarkerLineAlignment.outward, + ); + final canvas2 = _RecordingCanvas(); + outward.draw(canvas2, _markerInfo); + expect(canvas2.lines.first.p1, Offset.zero); + expect(canvas2.lines.first.p2, const Offset(10, 0)); + }); + + test( + 'GaugeMarkerLinePainter draws label when label + labelStyle set, ' + 'no flip on right hemisphere', () { + const painter = GaugeMarkerLinePainter( + label: 'X', + labelStyle: TextStyle(fontSize: 12), + ); + // angleDegrees=0 → cos(0) = 1 → no auto-flip. + const info = GaugeMarkerInfo( + value: 0.5, + minValue: 0, + maxValue: 1, + angleDegrees: 0, + ); + final canvas = _RecordingCanvas(); + painter.draw(canvas, info); + + // Line + text; text is recorded as a paragraph. + expect(canvas.lines.length, 1); + expect(canvas.paragraphs.length, 1); + // No flip → no save / translate / rotate around the text. + expect(canvas.transformOps, isEmpty); + }); + + test( + 'GaugeMarkerLinePainter auto-flips label on left hemisphere ' + 'so it stays upright', () { + const painter = GaugeMarkerLinePainter( + label: 'X', + labelStyle: TextStyle(fontSize: 12), + ); + // angleDegrees=180 → cos(π) = -1 → flip. + const info = GaugeMarkerInfo( + value: 0.5, + minValue: 0, + maxValue: 1, + angleDegrees: 180, + ); + final canvas = _RecordingCanvas(); + painter.draw(canvas, info); + + // Line drawn, text drawn, plus a save + translate + rotate(π) + + // restore around the text. + expect(canvas.lines.length, 1); + expect(canvas.paragraphs.length, 1); + expect(canvas.transformOps, ['save', 'translate', 'rotate', 'restore']); + expect(canvas.rotations.single, closeTo(pi, 1e-9)); + }); + + test( + 'GaugeMarkerLinePainter labelSide and labelOffset position the ' + 'text on either side of the line', () { + // outward side: text origin x ≈ lineEndX + labelOffset. + const outwardLabel = GaugeMarkerLinePainter( + length: 20, + label: 'X', + labelStyle: TextStyle(fontSize: 12), + labelOffset: 4, + ); + final canvas = _RecordingCanvas(); + outwardLabel.draw(canvas, _markerInfo); + // _lineEndX = 10 (centered, length 20), labelOffset = 4. + expect(canvas.paragraphOffsets.single.dx, closeTo(14, 1e-9)); + + // inward side: text origin x ≈ lineStartX - labelOffset - textWidth. + // Negative because the text sits at negative x. + const inwardLabel = GaugeMarkerLinePainter( + length: 20, + label: 'X', + labelStyle: TextStyle(fontSize: 12), + labelOffset: 4, + labelSide: GaugeMarkerLabelSide.inward, + ); + final canvas2 = _RecordingCanvas(); + inwardLabel.draw(canvas2, _markerInfo); + // _lineStartX = -10, labelOffset = 4 → text origin <= -14 (label + // width depends on the text painter's layout, which we can't + // hard-code, but it must be < -14). + expect(canvas2.paragraphOffsets.single.dx, lessThan(-14)); + }); + + test('GaugeMarkerLinePainter ignores label when labelStyle is null', () { + const painter = GaugeMarkerLinePainter(label: 'X'); + final canvas = _RecordingCanvas(); + painter.draw(canvas, _markerInfo); + expect(canvas.lines.length, 1); + expect(canvas.paragraphs, isEmpty); + }); + + test('GaugeMarkerLinePainter ignores empty label', () { + const painter = GaugeMarkerLinePainter( + label: '', + labelStyle: TextStyle(fontSize: 12), + ); + final canvas = _RecordingCanvas(); + painter.draw(canvas, _markerInfo); + expect(canvas.lines.length, 1); + expect(canvas.paragraphs, isEmpty); + }); + + test('GaugeChartData.markers default, copyWith, lerp', () { + final empty = GaugeChartData( + rings: const [GaugeProgressRing(value: 0.5, color: Colors.red)], + ); + expect(empty.markers, isEmpty); + + final withMarkers = empty.copyWith( + markers: const [GaugeMarker(value: 0.4)], + ); + expect(withMarkers.markers, hasLength(1)); + expect(withMarkers.markers.first.value, 0.4); + + // Lerp two charts with marker lists of equal length. + final a = empty.copyWith( + markers: const [GaugeMarker(value: 0.2)], + ); + final b = empty.copyWith( + markers: const [GaugeMarker(value: 0.8)], + ); + final mid = a.lerp(a, b, 0.5); + expect(mid.markers.single.value, closeTo(0.5, 1e-9)); + }); + test('GaugeChartData.pointers default, copyWith, lerp', () { final empty = GaugeChartData( rings: const [GaugeProgressRing(value: 0.5, color: Colors.red)], @@ -803,6 +1049,12 @@ class _RecordingCanvas implements Canvas { final List<_RecordedCircle> circles = <_RecordedCircle>[]; final List<_RecordedLine> lines = <_RecordedLine>[]; final List<_RecordedPath> paths = <_RecordedPath>[]; + final List paragraphs = []; + final List paragraphOffsets = []; + // Order of save / translate / rotate / restore calls — useful for + // verifying the auto-flip transform structure. + final List transformOps = []; + final List rotations = []; @override void drawCircle(Offset c, double radius, Paint paint) { @@ -819,6 +1071,52 @@ class _RecordingCanvas implements Canvas { paths.add(_RecordedPath(path, paint)); } + @override + void drawParagraph(Paragraph paragraph, Offset offset) { + paragraphs.add(paragraph); + paragraphOffsets.add(offset); + } + + @override + void save() => transformOps.add('save'); + + @override + void restore() => transformOps.add('restore'); + + @override + void translate(double dx, double dy) => transformOps.add('translate'); + + @override + void rotate(double radians) { + transformOps.add('rotate'); + rotations.add(radians); + } + @override dynamic noSuchMethod(Invocation invocation) => null; } + +const _markerInfo = GaugeMarkerInfo( + value: 0.5, + minValue: 0, + maxValue: 1, + angleDegrees: 0, +); + +/// Tiny marker painter used to exercise the cross-type lerp fallback +/// path in [GaugeMarkerLinePainter] / [GaugeMarker]. Not a built-in. +class _DummyMarkerPainter extends GaugeMarkerPainter { + const _DummyMarkerPainter(); + + @override + void draw(Canvas canvas, GaugeMarkerInfo markerInfo) {} + + @override + Size getSize() => Size.zero; + + @override + GaugeMarkerPainter lerp(GaugeMarkerPainter b, double t) => b; + + @override + List get props => const []; +} diff --git a/test/chart/gauge_chart/gauge_chart_painter_test.dart b/test/chart/gauge_chart/gauge_chart_painter_test.dart index fe008b85e..935275487 100644 --- a/test/chart/gauge_chart/gauge_chart_painter_test.dart +++ b/test/chart/gauge_chart/gauge_chart_painter_test.dart @@ -1180,4 +1180,313 @@ void main() { Utils.changeInstance(utilsMainInstance); }); }); + + group('drawMarkers()', () { + test('no-op when markers list is empty', () { + const viewSize = Size(400, 400); + final data = GaugeChartData( + rings: const [ + GaugeProgressRing(value: 1, color: Colors.red, width: 10), + ], + sweepAngle: 90, + ); + final gaugePainter = GaugeChartPainter(); + final holder = + PaintHolder(data, data, TextScaler.noScaling); + installIdentityUtilsMock(); + + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenAnswer((_) => viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + + gaugePainter.drawMarkers(mockCanvasWrapper, holder); + + verifyNever(mockCanvasWrapper.save()); + verifyNever(mockCanvasWrapper.translate(any, any)); + verifyNever(mockCanvasWrapper.rotate(any)); + Utils.changeInstance(utilsMainInstance); + }); + + test( + 'one save/translate/rotate/restore per marker, angle from value, ' + 'painter receives marker info', () { + const viewSize = Size(400, 400); + final captured = []; + final data = GaugeChartData( + maxValue: 100, + rings: const [ + GaugeProgressRing(value: 1, color: Colors.red, width: 10), + ], + startDegreeOffset: 0, + sweepAngle: 180, + markers: [ + GaugeMarker( + value: 0, + painter: _RecordingMarkerPainter(captured), + ), + GaugeMarker( + value: 25, + painter: _RecordingMarkerPainter(captured), + ), + GaugeMarker( + value: 100, + painter: _RecordingMarkerPainter(captured), + ), + ], + ); + final gaugePainter = GaugeChartPainter(); + final holder = + PaintHolder(data, data, TextScaler.noScaling); + installIdentityUtilsMock(); + + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenAnswer((_) => viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + + final rotations = []; + when(mockCanvasWrapper.rotate(captureAny)).thenAnswer((inv) { + rotations.add(inv.positionalArguments[0] as double); + }); + + gaugePainter.drawMarkers(mockCanvasWrapper, holder); + + verify(mockCanvasWrapper.save()).called(3); + verify(mockCanvasWrapper.restore()).called(3); + verify(mockCanvasWrapper.translate(any, any)).called(3); + + // value 0 → 0°, value 25 → 45°, value 100 → 180° (identity mock). + expect(rotations[0], 0); + expect(rotations[1], closeTo(45, 1e-9)); + expect(rotations[2], closeTo(180, 1e-9)); + + // Painter received the right marker value each time. + expect(captured.length, 3); + expect(captured[0].value, 0); + expect(captured[1].value, 25); + expect(captured[2].value, 100); + // minValue / maxValue propagated. + expect(captured.first.minValue, 0); + expect(captured.first.maxValue, 100); + // angleDegrees matches the canvas rotation: + // dir = 1, sweep = 180, start = 0 + // value 0 → 0°, value 25 → 45°, value 100 → 180°. + expect(captured[0].angleDegrees, 0); + expect(captured[1].angleDegrees, closeTo(45, 1e-9)); + expect(captured[2].angleDegrees, closeTo(180, 1e-9)); + Utils.changeInstance(utilsMainInstance); + }); + + test('counter-clockwise direction flips the angle sign', () { + const viewSize = Size(400, 400); + final data = GaugeChartData( + maxValue: 100, + rings: const [ + GaugeProgressRing(value: 1, color: Colors.red, width: 10), + ], + startDegreeOffset: 0, + sweepAngle: 180, + direction: GaugeDirection.counterClockwise, + markers: const [GaugeMarker(value: 50)], + ); + final gaugePainter = GaugeChartPainter(); + final holder = + PaintHolder(data, data, TextScaler.noScaling); + installIdentityUtilsMock(); + + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenAnswer((_) => viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + + final rotations = []; + when(mockCanvasWrapper.rotate(captureAny)).thenAnswer((inv) { + rotations.add(inv.positionalArguments[0] as double); + }); + + gaugePainter.drawMarkers(mockCanvasWrapper, holder); + + // dir = -1, progress 0.5 → angle = 0 + -1 * 180 * 0.5 = -90. + expect(rotations.single, closeTo(-90, 1e-9)); + Utils.changeInstance(utilsMainInstance); + }); + + test('inner position adds π to rotation so +x points toward center', () { + const viewSize = Size(400, 400); + final data = GaugeChartData( + rings: const [ + GaugeProgressRing(value: 1, color: Colors.red, width: 10), + ], + startDegreeOffset: 0, + sweepAngle: 90, + markers: const [ + GaugeMarker(value: 0, position: GaugeTickPosition.inner), + GaugeMarker(value: 1, position: GaugeTickPosition.inner), + ], + ); + final gaugePainter = GaugeChartPainter(); + final holder = + PaintHolder(data, data, TextScaler.noScaling); + installIdentityUtilsMock(); + + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenAnswer((_) => viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + + final rotations = []; + when(mockCanvasWrapper.rotate(captureAny)).thenAnswer((inv) { + rotations.add(inv.positionalArguments[0] as double); + }); + + gaugePainter.drawMarkers(mockCanvasWrapper, holder); + + expect(rotations.length, 2); + expect(rotations[0], closeTo(pi, 1e-9)); + expect(rotations[1], closeTo(90 + pi, 1e-9)); + Utils.changeInstance(utilsMainInstance); + }); + + test( + 'outer markers reserve padding so rings shrink to fit ' + '(picks max across ticks + markers)', () { + // Two charts: one with no overlay, one with a big outer marker. + // The marker should shrink the ring's outer radius by exactly + // (offset + painter width), proven by comparing drawArc rect. + const viewSize = Size(400, 400); + const ringWidth = 20.0; + + Rect? rectFor(GaugeChartData data) { + final gaugePainter = GaugeChartPainter(); + final holder = + PaintHolder(data, data, TextScaler.noScaling); + installIdentityUtilsMock(); + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenAnswer((_) => viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + Rect? captured; + when( + mockCanvasWrapper.drawArc(captureAny, any, any, any, any), + ).thenAnswer((inv) { + captured = inv.positionalArguments[0] as Rect; + }); + gaugePainter.drawSections(mockCanvasWrapper, holder); + Utils.changeInstance(utilsMainInstance); + return captured; + } + + final base = GaugeChartData( + rings: const [ + GaugeProgressRing(value: 1, color: Colors.red, width: ringWidth), + ], + sweepAngle: 90, + ); + + // Centered line (default): outward extent is length / 2. + // padding = offset (4) + length / 2 (6) = 10 → width diff = 20. + final centered = base.copyWith( + markers: const [ + GaugeMarker( + value: 0.5, + offset: 4, + painter: GaugeMarkerLinePainter(length: 12), + ), + ], + ); + final baseRect = rectFor(base)!; + final centeredRect = rectFor(centered)!; + expect(baseRect.width - centeredRect.width, closeTo(20, 1e-9)); + expect(baseRect.height - centeredRect.height, closeTo(20, 1e-9)); + + // Outward alignment: outward extent is the full length. + // padding = offset (4) + length (12) = 16 → width diff = 32. + final outward = base.copyWith( + markers: const [ + GaugeMarker( + value: 0.5, + offset: 4, + painter: GaugeMarkerLinePainter( + length: 12, + alignment: GaugeMarkerLineAlignment.outward, + ), + ), + ], + ); + final outwardRect = rectFor(outward)!; + expect(baseRect.width - outwardRect.width, closeTo(32, 1e-9)); + expect(baseRect.height - outwardRect.height, closeTo(32, 1e-9)); + }); + + test( + 'inner / center markers do not affect outer padding ' + '(rings keep their original radius)', () { + const viewSize = Size(400, 400); + const ringWidth = 20.0; + + Rect? rectFor(GaugeChartData data) { + final gaugePainter = GaugeChartPainter(); + final holder = + PaintHolder(data, data, TextScaler.noScaling); + installIdentityUtilsMock(); + final mockCanvasWrapper = MockCanvasWrapper(); + when(mockCanvasWrapper.size).thenAnswer((_) => viewSize); + when(mockCanvasWrapper.canvas).thenReturn(MockCanvas()); + Rect? captured; + when( + mockCanvasWrapper.drawArc(captureAny, any, any, any, any), + ).thenAnswer((inv) { + captured = inv.positionalArguments[0] as Rect; + }); + gaugePainter.drawSections(mockCanvasWrapper, holder); + Utils.changeInstance(utilsMainInstance); + return captured; + } + + final base = GaugeChartData( + rings: const [ + GaugeProgressRing(value: 1, color: Colors.red, width: ringWidth), + ], + sweepAngle: 90, + ); + final innerMarker = base.copyWith( + markers: const [ + GaugeMarker( + value: 0.5, + position: GaugeTickPosition.inner, + offset: 99, + painter: GaugeMarkerLinePainter(length: 99), + ), + ], + ); + final centerMarker = base.copyWith( + markers: const [ + GaugeMarker( + value: 0.5, + position: GaugeTickPosition.center, + offset: 99, + ), + ], + ); + + expect(rectFor(base), rectFor(innerMarker)); + expect(rectFor(base), rectFor(centerMarker)); + }); + }); +} + +class _RecordingMarkerPainter extends GaugeMarkerPainter { + _RecordingMarkerPainter(this.captured); + + final List captured; + + @override + void draw(Canvas canvas, GaugeMarkerInfo markerInfo) { + captured.add(markerInfo); + } + + @override + Size getSize() => Size.zero; + + @override + GaugeMarkerPainter lerp(GaugeMarkerPainter b, double t) => b; + + @override + List get props => [captured]; }