reclaim/app/lib/widgets/heatmap_grid.dart
flemming-it e08cedcd3f feat(brand): rename product to Recl∆Im (Reclaim) across app, bundle, docs
Recl∆Im — the F∆I product line for Pflicht-basierte
Wirkungsanalyse und Reform-Vorschlags-Pipelines. Replaces the
working title 'Law-Heatmap', which described the demo view
rather than the platform. Heatmap, Norms list, Hub view,
Methodik are now four panes of the same Recl∆Im app.

Renames in this commit:

  Flutter package      lawheatmap_app   -> reclaim_app
  macOS bundle id      ai.flemming.lawheatmapApp -> ai.flemming.reclaim
  macOS PRODUCT_NAME   lawheatmap_app   -> reclaim_app
  Theme tokens         LawHeatmapColors/Space/Radius/Typography/Theme
                       -> Reclaim*
  Surface card         LawHeatmapCard   -> ReclaimCard
  Top-level widget     LawHeatmapApp    -> ReclaimApp
  Theme files          lawheatmap_theme.dart/lawheatmap_tokens.dart
                       -> reclaim_theme.dart/reclaim_tokens.dart
  Widget file          lawheatmap_card.dart -> reclaim_card.dart
  Build script         build-macos.sh paths and headline string
  Docs                 MACHBARKEITSSTUDIE.md, METHODIK.md, RUN.md,
                       app/README.md, flow YAML
  UI strings           'F∆I Law-Heatmap' / 'Law-Heatmap'
                       -> 'Recl∆Im' / 'Recl∆Im (F∆I)' for vendor-
                       prefixed contexts

flutter analyze: clean. flutter test: 2/2 passing.

The local working directory stays fai_lawheatmap/ for now —
the dir is referenced by the .claude project metadata; renaming
it would break session continuity for no real gain. The Forgejo
repo was renamed via API in the same change-set (fai/lawheatmap
-> fai/reclaim), with the legacy slug serving a redirect
courtesy of Forgejo's built-in rename handling.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-18 13:42:55 +02:00

306 lines
8.4 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.

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,
});
final List<Evaluation> evaluations;
final void Function(Evaluation)? onTap;
@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,
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.theme,
});
final List<Evaluation> evaluations;
final int? hoverIndex;
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);
_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,
'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.evaluations.length != evaluations.length;
}