reclaim/app/lib/widgets/heatmap_grid.dart
flemming-it 4e00c83591 feat(ui): heatmap view modes (all / jurist-reviewed / marked)
A segmented toggle over the heatmap: 'Alle' shows every evaluation,
'Geprüft (N)' filters to jurist-confirmed ones, 'Markiert' shows all
with a confirm-green check on the reviewed points. Jurists are
optional — the platform works without them and the default is 'Alle';
review only confirms a figure. Empty filtered view gets its own hint.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-19 02:42:47 +02:00

343 lines
9.8 KiB
Dart
Raw Permalink 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.

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 (05), 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<Evaluation> 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<HeatmapGrid> createState() => _HeatmapGridState();
}
class _HeatmapGridState extends State<HeatmapGrid> {
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<Evaluation> 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 05',
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;
}