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:
commit
8ee6a294a1
64 changed files with 5641 additions and 0 deletions
306
app/lib/widgets/heatmap_grid.dart
Normal file
306
app/lib/widgets/heatmap_grid.dart
Normal 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 (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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue