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,98 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../theme/lawheatmap_tokens.dart';
/// The FI / ChIn brand mark: an upward triangle (the ``) with
/// a petrol "eye" at the centre. Geometry mirrors fai_web's
/// Logo.astro the triangle IS the "A"; the eye-dot IS the dot
/// of the "i".
///
/// Subtle 9s breathing pulse on the eye (matches fai_web's
/// `.eye-pulse`). Set [animated] to false for static contexts
/// (icons, screenshots).
class DeltaMark extends StatefulWidget {
const DeltaMark({super.key, this.size = 64, this.animated = true});
final double size;
final bool animated;
@override
State<DeltaMark> createState() => _DeltaMarkState();
}
class _DeltaMarkState extends State<DeltaMark>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 9),
);
if (widget.animated) {
_controller.repeat();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final t = _controller.value;
// Breathe: scale 1 1.28 1 over the cycle.
final pulse = 1.0 + 0.28 * (1 - (math.cos(t * 2 * math.pi).abs()));
return CustomPaint(
size: Size.square(widget.size),
painter: _DeltaPainter(pulse: widget.animated ? pulse : 1.0),
);
},
);
}
}
class _DeltaPainter extends CustomPainter {
_DeltaPainter({required this.pulse});
final double pulse;
@override
void paint(Canvas canvas, Size size) {
final w = size.width;
final h = size.height;
// Upward triangle, geometric centre.
final stroke = Paint()
..color = const Color(0xFFE9E7E2)
..style = PaintingStyle.stroke
..strokeWidth = w * 0.04
..strokeJoin = StrokeJoin.miter;
final inset = w * 0.08;
final tri = Path()
..moveTo(w / 2, inset)
..lineTo(w - inset, h - inset)
..lineTo(inset, h - inset)
..close();
canvas.drawPath(tri, stroke);
// Eye petrol fill, centred on the triangle's centroid.
final eyeCentre = Offset(w / 2, inset + (h - 2 * inset) * 2 / 3);
final eyeR = w * 0.06 * pulse;
final eye = Paint()..color = LawHeatmapColors.signal;
canvas.drawCircle(eyeCentre, eyeR, eye);
}
@override
bool shouldRepaint(covariant _DeltaPainter old) => old.pulse != pulse;
}

View file

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../theme/lawheatmap_tokens.dart';
/// Permanent banner shown above all data screens in mock mode.
/// Carries the political-defensibility caveat: this is demo data,
/// not peer-reviewed, not Juristen-approved.
class DemoBanner extends StatelessWidget {
const DemoBanner({super.key});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: LawHeatmapSpace.lg,
vertical: LawHeatmapSpace.sm,
),
decoration: BoxDecoration(
color: LawHeatmapColors.tierT4.withValues(alpha: 0.12),
border: Border(
bottom: BorderSide(
color: LawHeatmapColors.tierT4.withValues(alpha: 0.55),
),
),
),
child: Row(
children: [
const Icon(Icons.science_outlined,
size: 18, color: LawHeatmapColors.tierT4),
const SizedBox(width: LawHeatmapSpace.sm),
Expanded(
child: Text(
'Demo-Daten — Phase 0, ohne Verbands-Erhebung. '
'Keine Juristen-Approval, keine Beirats-Validierung. '
'Alle Werte tragen Tier T4, bis T1/T2-Pipeline läuft.',
style: t.labelSmall?.copyWith(
color: LawHeatmapColors.tierT4,
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import '../data/models.dart';
import '../theme/lawheatmap_tokens.dart';
import 'lawheatmap_card.dart';
import 'source_chip.dart';
/// Sidebar listing the citable sources behind an evaluation.
/// Surfaces the methodological audit story: every figure has a
/// source and an evidence tier.
class EvidenceSidebar extends StatelessWidget {
const EvidenceSidebar({super.key, required this.evaluation});
final Evaluation evaluation;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return LawHeatmapCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Quellen', style: t.headlineSmall),
const SizedBox(height: LawHeatmapSpace.sm),
Text(
'Jede Zahl trägt die Tier-Stufe ihres schwächsten '
'Bestandteils (Regel der niedrigsten Stufe, §6.7).',
style: t.labelSmall?.copyWith(color: LawHeatmapColors.mute),
),
const SizedBox(height: LawHeatmapSpace.lg),
for (final s in evaluation.sources) SourceChip(source: s),
const Divider(height: LawHeatmapSpace.xl),
Text('Audit-Event', style: t.labelLarge),
const SizedBox(height: 4),
SelectableText(
evaluation.auditEventId,
style: t.labelSmall?.copyWith(
fontFamily: LawHeatmapTypography.mono,
color: LawHeatmapColors.mute,
),
),
const SizedBox(height: LawHeatmapSpace.sm),
Text(
'Quell-SHA-256:',
style: t.labelSmall?.copyWith(color: LawHeatmapColors.mute),
),
SelectableText(
evaluation.norm.sourceSha256,
style: t.labelSmall?.copyWith(
fontFamily: LawHeatmapTypography.mono,
color: LawHeatmapColors.mute,
fontSize: 10,
),
),
],
),
);
}
}

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;
}

View file

@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import '../theme/lawheatmap_tokens.dart';
/// Surface container visual equivalent of fai_chain_studio's
/// `ChainCard`, retuned to the fai_web palette.
class LawHeatmapCard extends StatelessWidget {
const LawHeatmapCard({
super.key,
required this.child,
this.padding =
const EdgeInsets.all(LawHeatmapSpace.lg),
this.accent = false,
this.expand = false,
});
final Widget child;
final EdgeInsetsGeometry padding;
/// If true, draws a thin petrol accent bar at the top
/// used for "this card carries a primary action".
final bool accent;
/// If true, the card grows to fill its parent's bounded
/// dimensions and forces the child to expand. Use when the
/// card is placed inside an `Expanded` widget and the child
/// is itself a free-sizing widget (e.g. CustomPaint).
final bool expand;
@override
Widget build(BuildContext context) {
final padded = Padding(padding: padding, child: child);
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,
children: [
if (accent)
Container(
height: 2,
decoration: const BoxDecoration(
color: LawHeatmapColors.signal,
borderRadius: BorderRadius.vertical(
top: LawHeatmapRadius.md,
),
),
),
if (expand) Expanded(child: padded) else padded,
],
),
);
}
}

View file

@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import '../data/models.dart';
import '../theme/lawheatmap_tokens.dart';
import 'tier_badge.dart';
/// A citable source line: tier badge + label + (planned) link.
/// External link opening lands with `url_launcher` in week 1 for
/// now the chip surfaces the URL via tooltip + copy hint.
class SourceChip extends StatelessWidget {
const SourceChip({super.key, required this.source});
final Source source;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.only(bottom: LawHeatmapSpace.sm),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: TierBadge(tier: source.tier, compact: true),
),
const SizedBox(width: LawHeatmapSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(source.label, style: t.bodyLarge),
Text(
source.url,
style: t.labelSmall?.copyWith(
color: LawHeatmapColors.mute,
),
),
if (source.note != null)
Text(
source.note!,
style: t.labelSmall?.copyWith(
color: LawHeatmapColors.mute,
fontStyle: FontStyle.italic,
),
),
],
),
),
],
),
);
}
}

View file

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import '../data/models.dart';
import '../theme/lawheatmap_tokens.dart';
/// Tier badge surfaces the lowest evidence tier in a composed
/// value. Every figure in the app carries one of these next to it.
class TierBadge extends StatelessWidget {
const TierBadge({super.key, required this.tier, this.compact = false});
final EvidenceTier tier;
final bool compact;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final color = switch (tier) {
EvidenceTier.t1 => LawHeatmapColors.tierT1,
EvidenceTier.t2 => LawHeatmapColors.tierT2,
EvidenceTier.t3 => LawHeatmapColors.tierT3,
EvidenceTier.t4 => LawHeatmapColors.tierT4,
};
return Tooltip(
message: '${tier.short}${tier.description}',
child: Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 6 : LawHeatmapSpace.sm,
vertical: compact ? 2 : 4,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.18),
border: Border.all(color: color.withValues(alpha: 0.55)),
borderRadius: const BorderRadius.all(LawHeatmapRadius.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
tier.short,
style: (compact ? t.labelSmall : t.labelLarge)?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}