feat: initial F∆I Law-Heatmap Phase 0 scaffold

First end-to-end-buildable cut of the pflicht-/graph-basierte
Wirkungsanalyse von Rechtsnormen, running on Ch∆In. Demo runs
without external dependencies and without a hub connection so
the methodology + UI stack can be reviewed before any Verbands-
Kooperation or Beirats-Validierung kicks in.

Tree layout

  MACHBARKEITSSTUDIE.md       v0.3 — pflicht-basierte Methodik,
                              4-Tier-Evidenz, 8 law*-Module
                              auf Ch∆In statt der alten F∆I-
                              Plattform.
  RUN.md                      one-page how-to-run.
  flows/durchstich-gewo-14.yaml
                              vertical Phase-0 flow: pull →
                              normalize → duties → citations →
                              cohort → skm → frust → benefit →
                              attribution → system.approval.
  app/                        Flutter macOS client, design
                              language pulled from fai_web
                              (ink #08090A, paper #F4F3EF,
                              petrol signal #2E8F9E). Mock
                              repository ships five fixture
                              norms with real source URLs and a
                              permanent T4 demo-banner so no
                              figure can be mistaken for a
                              validated one.

Module repos (separate, see chain-modules*/):

  text-akoma-normalize, text-deontic-extract, graph-citation-
  extract, text-readability-score, stats-cohort-size, graph-
  shapley-attribution, econ-skm-score, law-benefit-score.

What runs today

  - flutter analyze: 0 issues
  - flutter test: 2/2 (landing + nav-to-shell smoke)
  - flutter build macos (via app/build-macos.sh) and ad-hoc
    codesign, app launches and the Dart VM service comes up
  - native cargo test green on every module
  - cargo build --release --target wasm32-wasip2 produces a
    130 KiB artefact for text-akoma-normalize

What is deliberately mock / stub

  - gRPC wire from the client to a chain serve hub (Repository
    abstraction is in place; live impl is the next step)
  - NKR Bürokratiekosten-Datenbank ingestion (for the canonical
    h-Werte that close out the Engpass per Studie §6.7)
  - DESTATIS GENESIS-API adapter for stats.cohort_size

License: Apache-2.0. Author/contact in MACHBARKEITSSTUDIE.md.
Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-18 11:26:28 +02:00
commit 8ee6a294a1
64 changed files with 5641 additions and 0 deletions

View file

@ -0,0 +1,306 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../data/models.dart';
import '../theme/lawheatmap_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 = LawHeatmapColors.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: LawHeatmapTypography.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(
LawHeatmapColors.benefitSaturated,
LawHeatmapColors.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: LawHeatmapTypography.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 = LawHeatmapColors.signal.withValues(alpha: 0.6);
final rrect = RRect.fromRectAndRadius(box, LawHeatmapRadius.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: LawHeatmapTypography.body,
),
);
_drawText(
canvas,
'€/Jahr: ${_eur(e.skmEurPerYear)} Betroffene: ${_kmu(e.affectedCount)}',
box.topLeft + const Offset(10, 26),
const TextStyle(
color: mute,
fontSize: 11,
fontFamily: LawHeatmapTypography.body,
),
);
_drawText(
canvas,
'Tier: ${e.tierLowest.short} Klick → Detail',
box.topLeft + const Offset(10, 44),
const TextStyle(
color: mute,
fontSize: 11,
fontFamily: LawHeatmapTypography.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;
}