Ocideck/lib/widgets/slides/previews/chart_preview.dart
Brenno de Winter 6b2ba4df89 Split slide_preview.dart and app_shell.dart into part files (#1)
Break the two largest widget files into part/part-of libraries grouped by
concern, with no public API or behaviour change (private widgets keep working
because parts share the library namespace; all imports stay in the main file).

  slide_preview.dart  4748 -> 426 lines + slides/previews/{text,bullets,
                      checklist,table,media,code,chart,overlays}.dart
  app_shell.dart      1930 -> 996 lines + shell/{shell_actions,tab_bar,
                      welcome_screen,status_bar,shell_overlays}.dart

fullscreen_presenter.dart is intentionally left as-is: ~1.6k of its lines are a
single interactive _FullscreenPresenterState (38 setState calls), which a
mechanical split cannot reduce and extensions can't host (protected setState).
Shrinking it needs a behaviour-affecting sub-widget extraction, tracked
separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:16:49 +02:00

1478 lines
54 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 = <BarChartGroupData>[];
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 = <LineChartBarData>[];
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<double>(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<Rect> rects, List<TextAlign> 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 = <Offset>[];
final sizes = <Size>[];
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 = <Rect>[];
final aligns = <TextAlign>[];
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<Rect> rects,
List<TextAlign> 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<double> values;
final List<String> labels;
final List<Color> 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<double>(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);