1479 lines
54 KiB
Dart
1479 lines
54 KiB
Dart
|
|
// 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);
|