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>
306 lines
8.4 KiB
Dart
306 lines
8.4 KiB
Dart
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 (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,
|
||
});
|
||
|
||
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 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(
|
||
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;
|
||
}
|