import 'dart:math' as math; import 'package:flutter/material.dart'; import '../data/models.dart'; import '../theme/reclaim_tokens.dart'; /// 2D scatter heatmap — x: Schaden (€/Jahr, log-scaled), /// y: Nutzen (0–5), point area: Betroffenheit (sqrt-scaled). /// /// Server delivers JSON; client pixels. class HeatmapGrid extends StatefulWidget { const HeatmapGrid({ super.key, required this.evaluations, this.onTap, this.markReviewed = false, }); final List evaluations; final void Function(Evaluation)? onTap; /// When true, points whose evaluation a jurist confirmed get a /// confirm marker (used by the heatmap's "Markiert" view mode). final bool markReviewed; @override State createState() => _HeatmapGridState(); } class _HeatmapGridState extends State { int? _hoverIndex; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final size = Size(constraints.maxWidth, constraints.maxHeight); return MouseRegion( onHover: (e) => _setHover(_hitTest(e.localPosition, size)), onExit: (_) => _setHover(null), child: GestureDetector( onTapDown: (e) { final idx = _hitTest(e.localPosition, size); if (idx != null && widget.onTap != null) { widget.onTap!(widget.evaluations[idx]); } }, child: CustomPaint( painter: _HeatmapPainter( evaluations: widget.evaluations, hoverIndex: _hoverIndex, markReviewed: widget.markReviewed, theme: Theme.of(context), ), size: size, ), ), ); }, ); } void _setHover(int? idx) { if (idx != _hoverIndex) setState(() => _hoverIndex = idx); } int? _hitTest(Offset pos, Size size) { final layout = _Layout(size); for (var i = 0; i < widget.evaluations.length; i++) { final c = layout.project(widget.evaluations[i]); final r = layout.radius(widget.evaluations[i]); if ((pos - c).distance <= r + 4) return i; } return null; } } class _Layout { _Layout(this.size) : padding = const EdgeInsets.fromLTRB(72, 28, 28, 48); final Size size; final EdgeInsets padding; double get plotLeft => padding.left; double get plotRight => size.width - padding.right; double get plotTop => padding.top; double get plotBottom => size.height - padding.bottom; double get plotWidth => plotRight - plotLeft; double get plotHeight => plotBottom - plotTop; /// x: log10(€/year), clipped to [3, 9]. static double xLog(double eur) { if (eur <= 1) return 3; final v = math.log(eur) / math.ln10; return v.clamp(3.0, 9.0); } Offset project(Evaluation e) { final x = xLog(e.skmEurPerYear); final y = e.benefitScore.clamp(0.0, 5.0); final dx = plotLeft + ((x - 3) / 6) * plotWidth; final dy = plotBottom - (y / 5) * plotHeight; return Offset(dx, dy); } double radius(Evaluation e) { final n = e.affectedCount.clamp(1, 10_000_000); final r = math.sqrt(n / 1000) * 1.6; return r.clamp(6.0, 44.0); } } class _HeatmapPainter extends CustomPainter { _HeatmapPainter({ required this.evaluations, required this.hoverIndex, required this.markReviewed, required this.theme, }); final List evaluations; final int? hoverIndex; final bool markReviewed; final ThemeData theme; @override void paint(Canvas canvas, Size size) { final layout = _Layout(size); final onSurface = theme.colorScheme.onSurface; const mute = ReclaimColors.mute; // Plot frame. final frame = Paint() ..color = onSurface.withValues(alpha: 0.14) ..style = PaintingStyle.stroke ..strokeWidth = 1; canvas.drawRect( Rect.fromLTRB( layout.plotLeft, layout.plotTop, layout.plotRight, layout.plotBottom, ), frame, ); // Grid lines (y = 1..4, x = 4..8). final gridPaint = Paint() ..color = onSurface.withValues(alpha: 0.08) ..strokeWidth = 1; for (var y = 1; y <= 4; y++) { final dy = layout.plotBottom - (y / 5) * layout.plotHeight; canvas.drawLine( Offset(layout.plotLeft, dy), Offset(layout.plotRight, dy), gridPaint, ); } for (var x = 4; x <= 8; x++) { final dx = layout.plotLeft + ((x - 3) / 6) * layout.plotWidth; canvas.drawLine( Offset(dx, layout.plotTop), Offset(dx, layout.plotBottom), gridPaint, ); } // Axis labels. const axisStyle = TextStyle( color: mute, fontSize: 10, fontFamily: ReclaimTypography.body, ); _drawText( canvas, 'Schaden €/Jahr (log)', Offset(layout.plotLeft, layout.plotBottom + 22), axisStyle, ); _drawText( canvas, 'Nutzen 0–5', Offset(8, layout.plotTop - 18), axisStyle, ); // X tick labels for 10^4 .. 10^9 for (var x = 4; x <= 9; x++) { final dx = layout.plotLeft + ((x - 3) / 6) * layout.plotWidth; _drawText( canvas, '10^$x', Offset(dx - 12, layout.plotBottom + 6), axisStyle, ); } // Y tick labels for 0..5 for (var y = 0; y <= 5; y++) { final dy = layout.plotBottom - (y / 5) * layout.plotHeight; _drawText( canvas, '$y', Offset(layout.plotLeft - 18, dy - 6), axisStyle, ); } // Points. for (var i = 0; i < evaluations.length; i++) { final e = evaluations[i]; final c = layout.project(e); final r = layout.radius(e); final hot = e.skmEurPerYear > 50000000; final color = Color.lerp( ReclaimColors.benefitSaturated, ReclaimColors.harmWarm, hot ? 0.5 : 0.0, )!; final body = Paint()..color = color.withValues(alpha: 0.7); final ring = Paint() ..color = color ..style = PaintingStyle.stroke ..strokeWidth = i == hoverIndex ? 3 : 1.5; canvas.drawCircle(c, r, body); canvas.drawCircle(c, r, ring); // Jurist-confirmed marker (heatmap "Markiert" mode): a small // confirm-green dot with a white check at the point's NE edge. if (markReviewed && e.isReviewed) { final m = Offset(c.dx + r * 0.72, c.dy - r * 0.72); canvas.drawCircle( m, 5.5, Paint()..color = ReclaimColors.reviewConfirmed); canvas.drawCircle( m, 5.5, Paint() ..color = theme.colorScheme.surface ..style = PaintingStyle.stroke ..strokeWidth = 1.5); final check = Path() ..moveTo(m.dx - 2.4, m.dy + 0.2) ..lineTo(m.dx - 0.6, m.dy + 2.0) ..lineTo(m.dx + 2.6, m.dy - 2.2); canvas.drawPath( check, Paint() ..color = theme.colorScheme.surface ..style = PaintingStyle.stroke ..strokeWidth = 1.4 ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round); } _drawText( canvas, '${e.norm.jurabk} ${e.norm.paragraph}', Offset(c.dx + r + 6, c.dy - 6), TextStyle( color: onSurface, fontSize: 11, fontFamily: ReclaimTypography.body, fontWeight: i == hoverIndex ? FontWeight.w600 : FontWeight.w400, ), ); } // Hover detail box. if (hoverIndex != null) { final e = evaluations[hoverIndex!]; final c = layout.project(e); final r = layout.radius(e); final box = Rect.fromLTWH(c.dx + r + 8, c.dy + 8, 240, 64); final boxPaint = Paint() ..color = theme.colorScheme.surface.withValues(alpha: 0.92); final boxBorder = Paint() ..style = PaintingStyle.stroke ..color = ReclaimColors.signal.withValues(alpha: 0.6); final rrect = RRect.fromRectAndRadius(box, ReclaimRadius.sm); canvas.drawRRect(rrect, boxPaint); canvas.drawRRect(rrect, boxBorder); _drawText( canvas, e.norm.title, box.topLeft + const Offset(10, 8), TextStyle( color: onSurface, fontSize: 12, fontWeight: FontWeight.w600, fontFamily: ReclaimTypography.body, ), ); _drawText( canvas, '€/Jahr: ${_eur(e.skmEurPerYear)} Betroffene: ${_kmu(e.affectedCount)}', box.topLeft + const Offset(10, 26), const TextStyle( color: mute, fontSize: 11, fontFamily: ReclaimTypography.body, ), ); _drawText( canvas, 'Bereich: ${e.norm.jurisdiction.short} ' 'Tier: ${e.tierLowest.short} Klick → Detail', box.topLeft + const Offset(10, 44), const TextStyle( color: mute, fontSize: 11, fontFamily: ReclaimTypography.body, ), ); } } String _eur(double v) { if (v >= 1e9) return '${(v / 1e9).toStringAsFixed(1)} Mrd'; if (v >= 1e6) return '${(v / 1e6).toStringAsFixed(1)} Mio'; if (v >= 1e3) return '${(v / 1e3).toStringAsFixed(0)} k'; return v.toStringAsFixed(0); } String _kmu(int n) { if (n >= 1_000_000) return '${(n / 1e6).toStringAsFixed(1)} Mio'; if (n >= 1_000) return '${(n / 1e3).toStringAsFixed(0)} k'; return n.toString(); } void _drawText(Canvas canvas, String text, Offset at, TextStyle style) { final tp = TextPainter( text: TextSpan(text: text, style: style), textDirection: TextDirection.ltr, )..layout(); tp.paint(canvas, at); } @override bool shouldRepaint(covariant _HeatmapPainter old) => old.hoverIndex != hoverIndex || old.markReviewed != markReviewed || old.evaluations.length != evaluations.length; }