// Part of the slide_preview library — see ../slide_preview.dart. // Split out for navigability; all imports live in the main library file. part of '../slide_preview.dart'; /// Renders a chart slide (bar/line/pie) from its ```chart JSON spec. class _ChartPreview extends StatefulWidget { final Slide slide; final double w; final String font; final ThemeProfile profile; final bool presentationMode; const _ChartPreview({ required this.slide, required this.w, required this.font, required this.profile, required this.presentationMode, }); @override State<_ChartPreview> createState() => _ChartPreviewState(); } class _ChartPreviewState extends State<_ChartPreview> { Slide get slide => widget.slide; double get w => widget.w; String get font => widget.font; ThemeProfile get profile => widget.profile; bool get presentationMode => widget.presentationMode; /// Legend entry the pointer is over: a series index for bar/line charts, or a /// slice (category) index for pie charts. Null when nothing is hovered. int? _hovered; /// The radar vertex under the pointer, used to draw its tooltip. Null when not /// hovering a point. ({int series, int entry, double value, Offset offset})? _radarTouch; void _setHover(int? index) { if (_hovered != index) setState(() => _hovered = index); } /// True when another legend entry is hovered, so [index] should fade back. bool _dimmed(int index) => _hovered != null && _hovered != index; /// Series colour with legend-hover feedback: non-hovered series fade out so /// the hovered one stands out in the plot. Color _seriesDisplayColor(ChartSeries series, int i) { final base = _seriesColor(series, i); return _dimmed(i) ? base.withValues(alpha: 0.2) : base; } double get _labelScale => presentationMode ? 1.12 : 1; Color _seriesColor(ChartSeries series, int i) { if (series.color == null && i == 0) { return _hexColor(profile.accentColor); } return _hexColor(chartSeriesColor(series, i)); } /// Text alternative for the chart (WCAG 1.1.1): chart type, title and the /// underlying values per series, so a screen reader conveys the same /// information the visual encodes. String _semanticsLabel(BuildContext context, ChartSpec spec) { final l10n = context.l10n; final typeName = switch (spec.type) { ChartType.bar => l10n.d('Staaf'), ChartType.line => l10n.d('Lijn'), ChartType.pie => l10n.d('Cirkel'), ChartType.radar => l10n.d('Spider'), }; final buffer = StringBuffer('${l10n.d('Grafiek')} ($typeName)'); if (spec.title.isNotEmpty) { buffer.write(': ${stripInlineMarkdown(spec.title)}'); } if (!spec.hasInlineData) return buffer.toString(); for (var si = 0; si < spec.series.length; si++) { final series = spec.series[si]; final name = series.name.isEmpty ? '${l10n.d('Reeks')} ${si + 1}' : series.name; final values = [ for (var xi = 0; xi < spec.x.length && xi < series.data.length; xi++) '${spec.x[xi]} ${_fmtNum(series.data[xi])}', ]; buffer.write('. $name: ${values.join(', ')}'); } return buffer.toString(); } @override Widget build(BuildContext context) { final spec = ChartSpec.parse(slide.customMarkdown); final horizontalPad = w * 0.05; final verticalPad = w * 0.018; final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; final textColor = _hexColor(profile.textColor); return Semantics( image: true, label: _semanticsLabel(context, spec), // The visual chart (axis labels, legend chips, tooltips) would read as // disconnected fragments; the label above carries the full story. child: ExcludeSemantics( child: _chartBody( context, spec, horizontalPad, verticalPad, safe, textColor, ), ), ); } Widget _chartBody( BuildContext context, ChartSpec spec, double horizontalPad, double verticalPad, EdgeInsets safe, Color textColor, ) { return Container( color: _hexColor(profile.slideBackgroundColor), child: Padding( padding: EdgeInsets.fromLTRB( horizontalPad, verticalPad + safe.top, horizontalPad, verticalPad + safe.bottom, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (spec.title.isNotEmpty) ...[ Container( width: double.infinity, padding: EdgeInsets.symmetric( horizontal: w * 0.025, vertical: w * 0.01, ), decoration: BoxDecoration( color: _hexColor(profile.titleBackgroundColor), borderRadius: BorderRadius.circular(w * 0.012), border: Border( left: BorderSide( color: _hexColor(profile.accentColor), width: w * 0.006, ), ), ), child: _md( context, spec.title, _applyFont( font, TextStyle( fontSize: w * 0.032, height: 1.1, fontWeight: FontWeight.bold, color: _hexColor(profile.titleTextColor), ), ), linkColor: _hexColor(profile.accentColor), ), ), SizedBox(height: w * 0.012), ], Expanded( child: Container( key: const ValueKey('chart-surface'), padding: EdgeInsets.fromLTRB( w * 0.02, w * 0.01, w * 0.025, w * 0.01, ), decoration: BoxDecoration( color: textColor.withValues(alpha: 0.035), borderRadius: BorderRadius.circular(w * 0.014), border: Border.all(color: textColor.withValues(alpha: 0.09)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: spec.hasInlineData ? _chart(spec, textColor) : _placeholder(context), ), if (spec.hasInlineData && spec.series.isNotEmpty) ...[ SizedBox(height: w * 0.006), spec.type == ChartType.pie ? _pieLegend(spec, textColor) : _legend(spec, textColor), ], ], ), ), ), ], ), ), ); } Widget _legend(ChartSpec spec, Color textColor) { return SizedBox( height: w * 0.03, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ for (var i = 0; i < spec.series.length; i++) ...[ if (i > 0) SizedBox(width: w * 0.01), MouseRegion( onEnter: (_) => _setHover(i), onExit: (_) => _setHover(null), child: AnimatedOpacity( duration: const Duration(milliseconds: 120), opacity: _dimmed(i) ? 0.4 : 1, child: Container( padding: EdgeInsets.symmetric( horizontal: w * 0.01, vertical: w * 0.004, ), decoration: BoxDecoration( color: _hovered == i ? _seriesColor( spec.series[i], i, ).withValues(alpha: 0.18) : textColor.withValues(alpha: 0.045), borderRadius: BorderRadius.circular(w), border: Border.all( color: _hovered == i ? _seriesColor(spec.series[i], i) : Colors.transparent, width: w * 0.0015, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: w * 0.012, height: w * 0.012, decoration: BoxDecoration( color: _seriesColor(spec.series[i], i), shape: BoxShape.circle, ), ), SizedBox(width: w * 0.006), ConstrainedBox( constraints: BoxConstraints(maxWidth: w * 0.16), child: Text( spec.series[i].name.isEmpty ? 'Reeks ${i + 1}' : spec.series[i].name, maxLines: 1, overflow: TextOverflow.ellipsis, style: _applyFont( font, TextStyle( fontSize: w * 0.013, fontWeight: FontWeight.w600, color: textColor.withValues(alpha: 0.82), ), ), ), ), ], ), ), ), ), ], ], ), ), ); } Widget _pieLegend(ChartSpec spec, Color textColor) { final itemCount = math.min(spec.x.length, 18); final columns = math.min(itemCount, presentationMode ? 4 : 6); final rows = (itemCount / columns).ceil(); return LayoutBuilder( builder: (context, constraints) { final gap = w * 0.006; final itemWidth = (constraints.maxWidth - gap * (columns - 1)) / columns; return SizedBox( height: rows * w * 0.03 * _labelScale + (rows - 1) * gap, child: Wrap( spacing: gap, runSpacing: gap, children: [ for (var i = 0; i < itemCount; i++) MouseRegion( onEnter: (_) => _setHover(i), onExit: (_) => _setHover(null), child: AnimatedOpacity( duration: const Duration(milliseconds: 120), opacity: _dimmed(i) ? 0.4 : 1, child: Container( width: itemWidth, height: w * 0.03 * _labelScale, padding: EdgeInsets.symmetric(horizontal: w * 0.008), decoration: BoxDecoration( color: _hovered == i ? _hexColor( chartRowColor(spec, i), ).withValues(alpha: 0.18) : textColor.withValues(alpha: 0.045), borderRadius: BorderRadius.circular(w), border: Border.all( color: _hovered == i ? _hexColor(chartRowColor(spec, i)) : Colors.transparent, width: w * 0.0015, ), ), child: Row( children: [ Container( width: w * 0.012, height: w * 0.012, decoration: BoxDecoration( color: _hexColor(chartRowColor(spec, i)), shape: BoxShape.circle, ), ), SizedBox(width: w * 0.006), Expanded( child: Text( spec.x[i], maxLines: 1, overflow: TextOverflow.ellipsis, style: _applyFont( font, TextStyle( fontSize: w * 0.013 * _labelScale, fontWeight: FontWeight.w600, color: textColor.withValues(alpha: 0.82), ), ), ), ), ], ), ), ), ), ], ), ); }, ); } Widget _chart(ChartSpec spec, Color textColor) { switch (spec.type) { case ChartType.bar: return _barChart(spec, textColor); case ChartType.line: return _lineChart(spec, textColor); case ChartType.pie: return _pieChart(spec, textColor); case ChartType.radar: return _radarChart(spec, textColor); } } double _maxY(ChartSpec spec) { var m = 0.0; for (final s in spec.series) { for (final v in s.data) { if (v > m) m = v; } } // Keep any bound line comfortably inside the plot so its label is visible. if (spec.supportsBounds) { for (final b in [spec.minBound, spec.maxBound]) { if (b != null && b > m) m = b; } } return m <= 0 ? 1 : m * 1.15; } double _minY(ChartSpec spec) { var m = 0.0; for (final s in spec.series) { for (final v in s.data) { if (v < m) m = v; } } if (spec.supportsBounds) { for (final b in [spec.minBound, spec.maxBound]) { if (b != null && b < m) m = b; } } return m >= 0 ? 0 : m * 1.15; } /// Optional min/max threshold lines drawn across the plot (bar/line only). ExtraLinesData _boundLines(ChartSpec spec) { if (!spec.supportsBoundLines) return const ExtraLinesData(); final dash = [ (w * 0.018).round().clamp(4, 14), (w * 0.01).round().clamp(3, 9), ]; HorizontalLine line(double value, Color color, String prefix) => HorizontalLine( y: value, color: color, strokeWidth: w * 0.0035, dashArray: dash, label: HorizontalLineLabel( show: true, alignment: Alignment.topRight, padding: EdgeInsets.only(right: w * 0.006, bottom: w * 0.002), style: _applyFont( font, TextStyle( fontSize: w * 0.0115 * _labelScale, color: color, fontWeight: FontWeight.w700, ), ), labelResolver: (_) => '$prefix ${_fmtNum(value)}', ), ); return ExtraLinesData( horizontalLines: [ if (spec.minBound != null) line(spec.minBound!, const Color(0xFFF59E0B), 'min'), if (spec.maxBound != null) line(spec.maxBound!, const Color(0xFFEF4444), 'max'), ], ); } FlTitlesData _titles(ChartSpec spec, Color textColor, {bool bars = false}) { final style = _applyFont( font, TextStyle( fontSize: w * 0.0115 * _labelScale, color: textColor.withValues(alpha: 0.88), fontWeight: presentationMode ? FontWeight.w600 : FontWeight.normal, ), ); return FlTitlesData( topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: w * 0.05 * _labelScale, getTitlesWidget: (value, meta) => Text( _fmtNum(value), style: style.copyWith(fontSize: w * 0.0105 * _labelScale), ), ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, interval: 1, reservedSize: w * 0.044 * _labelScale, getTitlesWidget: (value, meta) { final i = value.round(); final n = spec.x.length; if (i < 0 || i >= n) return const SizedBox.shrink(); // Show as many labels as fit without colliding: keep at least // [minSlot] of horizontal room per label, then thin them out // evenly based on the actual pixel spacing between points. Line // charts spread n points over n-1 intervals; bar groups are laid // out spaceEvenly, which puts their centres (axis + groupWidth) / // (n + 1) apart. final spacing = bars ? (meta.parentAxisSize + _barGroupWidth(spec)) / (n + 1) : (n > 1 ? meta.parentAxisSize / (n - 1) : meta.parentAxisSize); final minSlot = w * 0.085 * _labelScale; final step = math.max(1, (minSlot / spacing).ceil()); final lastMultiple = ((n - 1) ~/ step) * step; final lastGap = n - 1 - lastMultiple; final showLast = i == n - 1 && lastGap > step / 2; if (i % step != 0 && !showLast) return const SizedBox.shrink(); // The extra end label can sit closer than a full step to its // neighbour; shrink both of their slots to the real gap so they // never run through each other. var slotSteps = step.toDouble(); if (showLast || (i == lastMultiple && lastGap > step / 2)) { slotSteps = math.min(slotSteps, lastGap.toDouble()); } final slot = (slotSteps * spacing - w * 0.012).clamp( w * 0.04, w * 0.16, ); return Padding( padding: EdgeInsets.only(top: w * 0.008), child: SizedBox( width: slot, child: Text( spec.x[i], style: style, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, ), ), ); }, ), ), ); } String _fmtNum(double v) { if (v == v.roundToDouble()) return v.toInt().toString(); return v.toStringAsFixed(1); } FlGridData _grid(Color textColor) => FlGridData( show: true, drawVerticalLine: false, getDrawingHorizontalLine: (v) => FlLine(color: textColor.withValues(alpha: 0.12), strokeWidth: 1), ); /// Width of one bar rod, shared by the chart and the axis-label spacing. double _barRodWidth(ChartSpec spec) => (w * 0.032 / spec.series.length).clamp(w * 0.008, w * 0.022); /// Total width of one bar group: its rods plus fl_chart's default 2px /// spacing between rods within a group. double _barGroupWidth(ChartSpec spec) { final rods = math.max(1, spec.series.length); return rods * _barRodWidth(spec) + (rods - 1) * 2; } Widget _barChart(ChartSpec spec, Color textColor) { final groups = []; for (var xi = 0; xi < spec.x.length; xi++) { groups.add( BarChartGroupData( x: xi, barRods: [ for (var si = 0; si < spec.series.length; si++) if (xi < spec.series[si].data.length) BarChartRodData( toY: spec.series[si].data[xi], color: _seriesDisplayColor(spec.series[si], si), width: _barRodWidth(spec), borderRadius: BorderRadius.vertical( top: Radius.circular(w * 0.006), ), backDrawRodData: BackgroundBarChartRodData( show: true, toY: _maxY(spec), color: textColor.withValues(alpha: 0.025), ), ), ], ), ); } return BarChart( BarChartData( minY: _minY(spec), maxY: _maxY(spec), // The axis-label spacing in _titles assumes this layout; keep it // explicit rather than relying on fl_chart's default. alignment: BarChartAlignment.spaceEvenly, barGroups: groups, titlesData: _titles(spec, textColor, bars: true), gridData: _grid(textColor), borderData: FlBorderData(show: false), extraLinesData: _boundLines(spec), barTouchData: BarTouchData( enabled: true, mouseCursorResolver: (event, response) => response?.spot == null ? SystemMouseCursors.basic : SystemMouseCursors.click, touchTooltipData: BarTouchTooltipData( fitInsideHorizontally: true, fitInsideVertically: true, getTooltipColor: (_) => const Color(0xFF0F172A), getTooltipItem: (group, groupIndex, rod, rodIndex) { final label = group.x >= 0 && group.x < spec.x.length ? spec.x[group.x] : ''; final series = rodIndex < spec.series.length ? spec.series[rodIndex].name : ''; return BarTooltipItem( '$label\n${series.isEmpty ? 'Reeks ${rodIndex + 1}' : series}: ${_fmtNum(rod.toY)}', _tooltipStyle(), ); }, ), ), ), duration: Duration.zero, ); } Widget _lineChart(ChartSpec spec, Color textColor) { final bars = []; for (var si = 0; si < spec.series.length; si++) { bars.add( LineChartBarData( spots: [ for (var xi = 0; xi < spec.series[si].data.length; xi++) FlSpot(xi.toDouble(), spec.series[si].data[xi]), ], color: _seriesDisplayColor(spec.series[si], si), barWidth: w * (_hovered == si ? 0.0065 : 0.0045), isCurved: true, curveSmoothness: 0.22, dotData: FlDotData( show: true, getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter( radius: w * 0.005, color: _seriesDisplayColor(spec.series[si], si), strokeWidth: w * 0.0025, strokeColor: _hexColor(profile.slideBackgroundColor), ), ), belowBarData: BarAreaData( show: true, color: _seriesDisplayColor( spec.series[si], si, ).withValues(alpha: spec.series.length == 1 ? 0.14 : 0.05), ), ), ); } return LineChart( LineChartData( minY: _minY(spec), maxY: _maxY(spec), lineBarsData: bars, titlesData: _titles(spec, textColor), gridData: _grid(textColor), borderData: FlBorderData(show: false), extraLinesData: _boundLines(spec), lineTouchData: LineTouchData( enabled: true, // Measure proximity to the actual dot (x *and* y), not just the // column, so the tooltip belongs to the point under the cursor. distanceCalculator: (touch, spot) => (touch - spot).distance, touchSpotThreshold: (w * 0.02).clamp(8.0, 24.0).toDouble(), mouseCursorResolver: (event, response) => response?.lineBarSpots?.isEmpty ?? true ? SystemMouseCursors.basic : SystemMouseCursors.click, touchTooltipData: LineTouchTooltipData( fitInsideHorizontally: true, fitInsideVertically: true, getTooltipColor: (_) => const Color(0xFF0F172A), // Show every dot near the cursor. When several dots sit on (almost) // the same spot they all appear; the font shrinks to keep them // readable when stacked. getTooltipItems: (spots) { final style = _lineTooltipStyle(spots.length); return [ for (final spot in spots) LineTooltipItem( '${spot.spotIndex < spec.x.length ? spec.x[spot.spotIndex] : ''}\n' '${spot.barIndex < spec.series.length && spec.series[spot.barIndex].name.isNotEmpty ? spec.series[spot.barIndex].name : 'Reeks ${spot.barIndex + 1}'}: ${_fmtNum(spot.y)}', style, ), ]; }, ), ), ), duration: Duration.zero, ); } Widget _pieChart(ChartSpec spec, Color textColor) { if (spec.series.isEmpty || spec.x.isEmpty) { return _placeholderText('—'); } return LayoutBuilder( builder: (context, constraints) { final visibleSeries = math.min(spec.series.length, 2); final columns = visibleSeries; const rows = 1; final tileHeight = constraints.maxHeight / rows; final tileWidth = constraints.maxWidth / columns; return GridView.builder( padding: EdgeInsets.zero, physics: const NeverScrollableScrollPhysics(), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: columns, childAspectRatio: tileWidth / tileHeight, crossAxisSpacing: w * 0.012, mainAxisSpacing: w * 0.008, ), itemCount: visibleSeries, itemBuilder: (context, si) { final series = spec.series[si]; final values = [ for (var xi = 0; xi < spec.x.length; xi++) xi < series.data.length && series.data[xi] > 0 ? series.data[xi] : 0.0, ]; final total = values.fold(0, (a, b) => a + b); return Row( children: [ Expanded( flex: 4, child: total <= 0 ? Center( child: Text( '0', style: _applyFont( font, TextStyle( fontSize: w * 0.025, color: textColor.withValues(alpha: 0.5), ), ), ), ) : LayoutBuilder( builder: (context, pieConstraints) { final available = pieConstraints.biggest.shortestSide; final radius = (available * 0.42).clamp( w * 0.018, w * 0.075, ); return ClipRect( child: _HoverPieChart( externalHover: _hovered, values: values, labels: spec.x, colors: [ for (var xi = 0; xi < values.length; xi++) _hexColor(chartRowColor(spec, xi)), ], radius: radius, centerSpaceRadius: radius * 0.42, sectionSpace: w * 0.002, titleStyle: _applyFont( font, TextStyle( fontSize: (radius * 0.18).clamp( w * 0.009, w * 0.013, ), color: Colors.white, fontWeight: FontWeight.bold, ), ), tooltipStyle: _tooltipStyle(), ), ); }, ), ), SizedBox(width: w * 0.008), Expanded( flex: 2, child: Text( series.name.isEmpty ? 'Reeks ${si + 1}' : series.name, maxLines: 3, overflow: TextOverflow.ellipsis, style: _applyFont( font, TextStyle( fontSize: w * 0.015, height: 1.1, fontWeight: FontWeight.w700, color: textColor, ), ), ), ), ], ); }, ); }, ); } Widget _radarChart(ChartSpec spec, Color textColor) { if (spec.x.length < 3 || spec.series.isEmpty) { return _placeholderText( context.l10n.d('Een spider-diagram heeft minstens drie labels nodig'), ); } final grid = textColor.withValues(alpha: 0.18); final scale = radarScale(spec); return Padding( padding: EdgeInsets.symmetric(horizontal: w * 0.02, vertical: w * 0.012), child: LayoutBuilder( builder: (context, constraints) { // Reserve a slim column on the right for the scale legend; the rest // of the area is shared between the spider and its axis labels. final legendWidth = w * 0.075; final boxW = math.max( 0.0, constraints.maxWidth - legendWidth - w * 0.02, ); final boxH = constraints.maxHeight; if (boxW <= 0 || !boxH.isFinite || boxH <= 0) { return const SizedBox.shrink(); } // Measure every axis label and grow the spider until the labels just // fit between the polygon and the edges of the available area, so // the diagram uses the space the old fixed label bands wasted. final layout = _radarLabelLayout(spec, boxW, boxH, textColor); final chartSide = layout.chartSide; return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Center( child: SizedBox( width: boxW, height: boxH, child: Stack( children: [ for (var i = 0; i < spec.x.length; i++) _radarAxisLabel( label: spec.x[i], index: i, count: spec.x.length, layout: layout, textColor: textColor, ), Positioned( left: (boxW - chartSide) / 2, top: (boxH - chartSide) / 2, width: chartSide, height: chartSide, child: Stack( clipBehavior: Clip.none, children: [ Positioned.fill( child: RadarChart( RadarChartData( dataSets: [ for ( var si = 0; si < spec.series.length; si++ ) RadarDataSet( dataEntries: [ for ( var xi = 0; xi < spec.x.length; xi++ ) RadarEntry( value: xi < spec .series[si] .data .length ? spec.series[si].data[xi] : 0, ), ], fillColor: _seriesDisplayColor( spec.series[si], si, ).withValues( alpha: _dimmed(si) ? 0.04 : 0.16, ), borderColor: _seriesDisplayColor( spec.series[si], si, ), borderWidth: w * (_hovered == si ? 0.0055 : 0.0035), entryRadius: w * (_hovered == si ? 0.006 : 0.004), ), // Invisible anchor pinning the scale to [lo, hi] // so the rings represent a fixed scale. RadarDataSet( dataEntries: [ for ( var xi = 0; xi < spec.x.length; xi++ ) RadarEntry( value: xi == 0 ? scale.hi : scale.lo, ), ], fillColor: Colors.transparent, borderColor: Colors.transparent, borderWidth: 0, entryRadius: 0, ), ], radarShape: RadarShape.polygon, radarBackgroundColor: Colors.transparent, radarBorderData: BorderSide( color: grid, width: 1, ), gridBorderData: BorderSide( color: grid, width: 1, ), tickBorderData: BorderSide( color: grid, width: 1, ), tickCount: scale.ticks, isMinValueAtCenter: true, // The scale now lives in a side legend, so hide // fl_chart's in-chart ring numbers. ticksTextStyle: const TextStyle( color: Colors.transparent, fontSize: 0.001, ), titlePositionPercentageOffset: 0, getTitle: (index, angle) => RadarChartTitle( text: index < spec.x.length ? spec.x[index] : '', ), // Labels are rendered as constrained widgets // around the chart so long text can wrap. titleTextStyle: const TextStyle( color: Colors.transparent, fontSize: 0.001, ), radarTouchData: RadarTouchData( enabled: true, touchSpotThreshold: (w * 0.02) .clamp(8.0, 24.0) .toDouble(), mouseCursorResolver: (event, response) => _radarSpotFrom(response, spec) == null ? SystemMouseCursors.basic : SystemMouseCursors.click, touchCallback: (event, response) { final next = event.isInterestedForInteractions ? _radarSpotFrom(response, spec) : null; if (next != _radarTouch) { setState(() => _radarTouch = next); } }, ), ), duration: Duration.zero, ), ), if (_radarTouch != null) _radarTooltip(spec, chartSide, _radarTouch!), ], ), ), ], ), ), ), ), SizedBox( width: legendWidth, child: _radarScaleLegend(scale, textColor), ), ], ); }, ), ); } TextStyle _radarLabelStyle(int count, Color textColor) => _applyFont( font, TextStyle( fontSize: w * (count <= 6 ? 0.013 : 0.0115) * _labelScale, height: 1.05, color: textColor.withValues(alpha: 0.88), fontWeight: presentationMode ? FontWeight.w600 : FontWeight.w500, ), ); /// True when the vertex in [direction] gets its label placed beside the /// polygon (left/right) rather than above/below it. static bool _radarLabelBeside(Offset direction) => direction.dx.abs() > 0.35; /// Sizes the spider and places every axis label around it. /// /// Each label is measured at its real text size, then the polygon radius is /// grown until the tightest label exactly fits between the polygon and the /// edge of the [boxW]×[boxH] area. fl_chart draws the polygon at 0.4× the /// side of its (square) widget, which is what ties [chartSide] to the /// resulting radius. ({double chartSide, List rects, List aligns, int maxLines}) _radarLabelLayout(ChartSpec spec, double boxW, double boxH, Color textColor) { const radiusFactor = 0.4; // fl_chart: radius = min(w, h) / 2 * 0.8 final n = spec.x.length; final style = _radarLabelStyle(n, textColor); final gap = w * 0.008; final maxLines = n <= 6 ? 3 : 2; final sideCap = math.min(boxW * 0.28, w * 0.2); final topCap = math.min(boxW * 0.5, w * 0.3); Size measure(String text, double maxWidth) { final painter = TextPainter( text: TextSpan(text: text, style: style), textDirection: TextDirection.ltr, maxLines: maxLines, ellipsis: '…', )..layout(maxWidth: math.max(0.0, maxWidth)); final size = Size(painter.width, painter.height); painter.dispose(); return size; } final directions = []; final sizes = []; for (var i = 0; i < n; i++) { final angle = (2 * math.pi * i / n) - math.pi / 2; final dir = Offset(math.cos(angle), math.sin(angle)); directions.add(dir); sizes.add(measure(spec.x[i], _radarLabelBeside(dir) ? sideCap : topCap)); } // The largest polygon radius every label still fits next to. var radius = radiusFactor * math.min(boxW, boxH); for (var i = 0; i < n; i++) { final dx = directions[i].dx.abs(); final dy = directions[i].dy.abs(); if (_radarLabelBeside(directions[i])) { radius = math.min(radius, (boxW / 2 - gap - sizes[i].width) / dx); if (dy > 0.01) { radius = math.min(radius, (boxH / 2 - sizes[i].height / 2) / dy); } } else { radius = math.min(radius, (boxH / 2 - gap - sizes[i].height) / dy); if (dx > 0.01) { radius = math.min(radius, (boxW / 2 - sizes[i].width / 2) / dx); } } } // Never let extreme labels crush the spider entirely; below this floor the // labels get clamped (and ellipsized) instead. final floor = 0.18 * math.min(boxW, boxH); radius = radius.clamp( math.min(floor, radiusFactor * math.min(boxW, boxH)), radiusFactor * math.min(boxW, boxH), ); final chartSide = radius / radiusFactor; final center = Offset(boxW / 2, boxH / 2); final rects = []; final aligns = []; for (var i = 0; i < n; i++) { final dir = directions[i]; final anchor = center + dir * (radius + gap); var size = sizes[i]; double left; double top; if (_radarLabelBeside(dir)) { // Re-measure against the room actually left beside the polygon, so a // clamped radius still produces a label that wraps inside the box. final room = dir.dx > 0 ? boxW - anchor.dx : anchor.dx; if (size.width > room) size = measure(spec.x[i], room); left = dir.dx > 0 ? anchor.dx : anchor.dx - size.width; top = anchor.dy - size.height / 2; aligns.add(dir.dx > 0 ? TextAlign.left : TextAlign.right); } else { left = anchor.dx - size.width / 2; top = dir.dy < 0 ? anchor.dy - size.height : anchor.dy; aligns.add(TextAlign.center); } rects.add( Rect.fromLTWH( left.clamp(0.0, math.max(0.0, boxW - size.width)), top.clamp(0.0, math.max(0.0, boxH - size.height)), size.width, size.height, ), ); } return ( chartSide: chartSide, rects: rects, aligns: aligns, maxLines: maxLines, ); } Widget _radarAxisLabel({ required String label, required int index, required int count, required ({ double chartSide, List rects, List aligns, int maxLines, }) layout, required Color textColor, }) { final rect = layout.rects[index]; return Positioned( key: ValueKey('radar-axis-label-$index'), left: rect.left, top: rect.top, width: rect.width, height: rect.height, child: Text( label, maxLines: layout.maxLines, overflow: TextOverflow.ellipsis, textAlign: layout.aligns[index], style: _radarLabelStyle(count, textColor), ), ); } /// Extract the touched real-series vertex from a radar touch response, /// ignoring the invisible scale anchor dataset. ({int series, int entry, double value, Offset offset})? _radarSpotFrom( RadarTouchResponse? response, ChartSpec spec, ) { final spot = response?.touchedSpot; if (spot == null) return null; if (spot.touchedDataSetIndex < 0 || spot.touchedDataSetIndex >= spec.series.length) { return null; // the anchor dataset, or out of range } return ( series: spot.touchedDataSetIndex, entry: spot.touchedRadarEntryIndex, value: spot.touchedRadarEntry.value, offset: spot.offset, ); } /// A small floating tooltip for the hovered radar vertex, like the other /// charts: the axis label, the series name and the value. Widget _radarTooltip( ChartSpec spec, double side, ({int series, int entry, double value, Offset offset}) touch, ) { final axis = touch.entry >= 0 && touch.entry < spec.x.length ? spec.x[touch.entry] : ''; final series = touch.series < spec.series.length ? spec.series[touch.series].name : ''; final label = series.isEmpty ? 'Reeks ${touch.series + 1}' : series; final onLeftHalf = touch.offset.dx <= side / 2; return Positioned( left: onLeftHalf ? (touch.offset.dx + w * 0.012) : null, right: onLeftHalf ? null : (side - touch.offset.dx + w * 0.012), top: (touch.offset.dy - w * 0.03).clamp(0.0, math.max(0.0, side - 1)), child: IgnorePointer( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: side * 0.6), child: Container( padding: EdgeInsets.symmetric( horizontal: w * 0.012, vertical: w * 0.006, ), decoration: BoxDecoration( color: const Color(0xFF0F172A), borderRadius: BorderRadius.circular(w * 0.008), boxShadow: const [ BoxShadow(color: Color(0x33000000), blurRadius: 6), ], ), child: Text( '${axis.isEmpty ? '' : '$axis\n'}$label: ${_fmtNum(touch.value)}', maxLines: 3, overflow: TextOverflow.ellipsis, style: _tooltipStyle(), ), ), ), ), ); } /// Vertical scale legend shown to the right of a radar chart: the tick values /// from the outer ring (top) down to the centre (bottom), in a small font. Widget _radarScaleLegend( ({double lo, double hi, int ticks}) scale, Color textColor, ) { final style = _applyFont( font, TextStyle( fontSize: w * 0.012 * _labelScale, color: textColor.withValues(alpha: 0.62), fontWeight: FontWeight.w600, ), ); final tickColor = textColor.withValues(alpha: 0.3); return Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ for (var k = scale.ticks; k >= 0; k--) ...[ if (k != scale.ticks) SizedBox(height: w * 0.018 * _labelScale), Row( mainAxisSize: MainAxisSize.min, children: [ Container(width: w * 0.012, height: 1, color: tickColor), SizedBox(width: w * 0.006), Flexible( child: Text( _fmtNum(scale.lo + (scale.hi - scale.lo) * k / scale.ticks), style: style, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), ], ], ); } /// Resolves the radar scale: a low/high pair plus an even tick count. Honours /// the optional [ChartSpec.minBound]/[maxBound] and otherwise rounds the data /// range to a tidy scale so the rings read as round numbers. ({double lo, double hi, int ticks}) radarScale(ChartSpec spec) { var dataMin = 0.0; var dataMax = 0.0; var seen = false; for (final s in spec.series) { for (final v in s.data) { if (!seen) { dataMin = v; dataMax = v; seen = true; } else { if (v < dataMin) dataMin = v; if (v > dataMax) dataMax = v; } } } if (!seen) { dataMin = 0; dataMax = 1; } final rawLo = spec.minBound ?? (dataMin < 0 ? dataMin : 0); final rawHi = spec.maxBound ?? dataMax; final nice = _niceScale(rawLo, rawHi); final lo = spec.minBound ?? nice.lo; var hi = spec.maxBound ?? nice.hi; if (hi <= lo) hi = lo + nice.step; final ticks = math.max(2, ((hi - lo) / nice.step).round()); return (lo: lo, hi: hi, ticks: ticks); } ({double lo, double hi, double step}) _niceScale(double lo, double hi) { final range = (hi - lo).abs(); final r = range <= 0 ? 1.0 : range; final rawStep = r / 4; final mag = math .pow(10, (math.log(rawStep) / math.ln10).floor()) .toDouble(); final norm = rawStep / mag; final niceNorm = norm < 1.5 ? 1.0 : norm < 3 ? 2.0 : norm < 7 ? 5.0 : 10.0; final step = niceNorm * mag; return ( lo: (lo / step).floor() * step, hi: (hi / step).ceil() * step, step: step, ); } TextStyle _tooltipStyle() => _applyFont( font, TextStyle( color: Colors.white, fontSize: (w * 0.013 * _labelScale).clamp(11, 18), height: 1.25, fontWeight: FontWeight.w700, ), ); /// Tooltip style for line charts. Each touched dot adds two lines, so when /// several dots overlap the font shrinks a step to keep the stack readable. TextStyle _lineTooltipStyle(int count) { final base = (w * 0.013 * _labelScale).clamp(11.0, 18.0); final shrink = (1 - (count - 2) * 0.13).clamp(0.6, 1.0); return _applyFont( font, TextStyle( color: Colors.white, fontSize: (base * shrink).clamp(8.0, 18.0), height: 1.2, fontWeight: FontWeight.w700, ), ); } Widget _placeholder(BuildContext context) => _placeholderText(context.l10n.d('Geen grafiekgegevens')); Widget _placeholderText(String text) => Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.bar_chart_outlined, size: w * 0.08, color: const Color(0xFF94A3B8), ), SizedBox(height: w * 0.01), Text( text, style: TextStyle(color: const Color(0xFF94A3B8), fontSize: w * 0.02), ), ], ), ); } class _HoverPieChart extends StatefulWidget { final List values; final List labels; final List colors; final double radius; final double centerSpaceRadius; final double sectionSpace; final TextStyle titleStyle; final TextStyle tooltipStyle; /// Slice index highlighted from outside (e.g. hovering the legend), combined /// with this chart's own touch hover. final int? externalHover; const _HoverPieChart({ required this.values, required this.labels, required this.colors, required this.radius, required this.centerSpaceRadius, required this.sectionSpace, required this.titleStyle, required this.tooltipStyle, this.externalHover, }); @override State<_HoverPieChart> createState() => _HoverPieChartState(); } class _HoverPieChartState extends State<_HoverPieChart> { int? _hovered; @override Widget build(BuildContext context) { final total = widget.values.fold(0, (a, b) => a + b); final external = widget.externalHover; final hovered = _hovered ?? (external != null && external >= 0 && external < widget.values.length ? external : null); return Stack( clipBehavior: Clip.none, children: [ Positioned.fill( child: PieChart( PieChartData( sections: [ for (var i = 0; i < widget.values.length; i++) PieChartSectionData( value: widget.values[i], color: widget.colors[i], title: widget.values[i] / total >= 0.08 ? '${(widget.values[i] / total * 100).round()}%' : '', radius: widget.radius * (hovered == i ? 1.08 : 1), titleStyle: widget.titleStyle, ), ], sectionsSpace: widget.sectionSpace, centerSpaceRadius: widget.centerSpaceRadius, pieTouchData: PieTouchData( enabled: true, mouseCursorResolver: (event, response) => response?.touchedSection == null ? SystemMouseCursors.basic : SystemMouseCursors.click, touchCallback: (event, response) { final next = event.isInterestedForInteractions ? response?.touchedSection?.touchedSectionIndex : null; if (next != _hovered) setState(() => _hovered = next); }, ), ), duration: Duration.zero, ), ), if (hovered != null && hovered >= 0 && hovered < widget.values.length) Positioned( top: 4, left: 4, right: 4, child: IgnorePointer( child: Center( child: Container( key: const ValueKey('pie-hover-tooltip'), padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6, ), decoration: BoxDecoration( color: const Color(0xFF0F172A), borderRadius: BorderRadius.circular(8), boxShadow: const [ BoxShadow(color: Color(0x33000000), blurRadius: 6), ], ), child: Text( '${widget.labels[hovered]}: ${_formatChartValue(widget.values[hovered])}', maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: widget.tooltipStyle, ), ), ), ), ), ], ); } } String _formatChartValue(double value) => value == value.roundToDouble() ? value.toInt().toString() : value.toStringAsFixed(1);