reclaim/app/lib/pages/heatmap_page.dart
flemming-it f61865a3c2 feat(ui): add legend card below the heatmap
The four-line description above the heatmap was concise but
opaque: 'X: log10 €/year. Y: 0–5. Punktgröße.' said the right
words, but a reviewer who isn't already methodologically
oriented can't tell what they're looking at.

Adds a dedicated Legende-Karte directly under the heatmap.
Four rows, each: leading symbol (↔ ↕ ● ◐), axis-or-quantity
label, prominent name (Schaden / Nutzen / Betroffenheit /
Evidenz-Stufe), and a short prose explanation tying the visual
back to the Studie:

  ↔  Abszisse (X)  Schaden       — log10 €/Jahr, SKM §3.1
  ↕  Ordinate (Y)  Nutzen        — 7-dim. Komposit §3.2
  ●  Punktgröße    Betroffenheit — sqrt scale, DESTATIS-/IHK
  ◐  Tier-Farbe    Evidenz-Stufe — Regel der niedrigsten §6.7

Top description tightened to one line of context only:
'Schaden × Nutzen × Betroffenheit pro Norm. Klick auf einen
Punkt öffnet das Detail …'. No redundancy with the legend,
no jargon up top.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-18 14:41:07 +02:00

261 lines
8.7 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 'package:flutter/material.dart';
import '../data/models.dart';
import '../data/repository.dart';
import '../theme/reclaim_tokens.dart';
import '../widgets/demo_banner.dart';
import '../widgets/heatmap_grid.dart';
import '../widgets/reclaim_card.dart';
import '../widgets/tier_badge.dart';
import 'norm_detail_page.dart';
class HeatmapPage extends StatefulWidget {
const HeatmapPage({super.key, required this.repository});
final EvaluationRepository repository;
@override
State<HeatmapPage> createState() => _HeatmapPageState();
}
class _HeatmapPageState extends State<HeatmapPage> {
late Future<List<Evaluation>> _future;
@override
void initState() {
super.initState();
_future = widget.repository.list();
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Column(
children: [
const DemoBanner(),
Expanded(
child: Padding(
padding: const EdgeInsets.all(ReclaimSpace.xl),
child: FutureBuilder<List<Evaluation>>(
future: _future,
builder: (context, snap) {
if (!snap.hasData) {
return const Center(
child: CircularProgressIndicator());
}
final items = snap.data!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Heatmap', style: t.displaySmall),
const SizedBox(height: ReclaimSpace.xs),
Text(
'Schaden × Nutzen × Betroffenheit pro Norm. '
'Klick auf einen Punkt öffnet das Detail mit '
'Pflichten, Quellen und Gesetzestext.',
style: t.bodyLarge?.copyWith(
color: ReclaimColors.mute,
),
),
const SizedBox(height: ReclaimSpace.lg),
Expanded(
child: ReclaimCard(
accent: true,
expand: true,
child: HeatmapGrid(
evaluations: items,
onTap: (e) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NormDetailPage(
repository: widget.repository,
evaluation: e,
),
),
);
},
),
),
),
const SizedBox(height: ReclaimSpace.md),
_LegendCard(),
const SizedBox(height: ReclaimSpace.lg),
Wrap(
spacing: ReclaimSpace.md,
runSpacing: ReclaimSpace.sm,
children: [
for (final e in items)
InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NormDetailPage(
repository: widget.repository,
evaluation: e,
),
),
);
},
child: ReclaimCard(
padding: const EdgeInsets.symmetric(
horizontal: ReclaimSpace.md,
vertical: ReclaimSpace.sm,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
TierBadge(
tier: e.tierLowest, compact: true),
const SizedBox(
width: ReclaimSpace.sm),
Text(
'${e.norm.jurabk} ${e.norm.paragraph}',
style: t.labelLarge,
),
],
),
),
),
],
),
],
);
},
),
),
),
],
);
}
}
/// Axis + circle legend below the heatmap. Spells out what is
/// plotted and how — the four-line top description deliberately
/// stays short and high-level, this card carries the precise
/// reading instructions a reviewer needs to defend the plot
/// against "what does that even mean?"
class _LegendCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return ReclaimCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Legende', style: t.headlineSmall),
const SizedBox(height: ReclaimSpace.md),
_LegendRow(
symbol: '',
axis: 'Abszisse (X)',
label: 'Schaden',
description:
'Bürokratiekosten pro Jahr in Euro, logarithmisch '
'skaliert (10⁴ = 10 k, 10⁶ = 1 Mio, 10⁹ = 1 Mrd). '
'Berechnet nach Standardkostenmodell '
'P × F × T × h (Studie §3.1).',
),
const SizedBox(height: ReclaimSpace.sm),
_LegendRow(
symbol: '',
axis: 'Ordinate (Y)',
label: 'Nutzen',
description:
'Komposit-Score von 05, Mittelwert aus den sieben '
'Dimensionen Schutz · Markt · Rechtssicherheit · '
'EU-Binnenmarkt · Sicherheit · Umwelt · Einnahmen '
'(Studie §3.2). Jede Dimension einzeln '
'quellen-begründet.',
),
const SizedBox(height: ReclaimSpace.sm),
_LegendRow(
symbol: '',
axis: 'Punktgröße',
label: 'Betroffenheit',
description:
'Anzahl der Adressaten (KMU, Bürger, Behörden), '
'wurzel-skaliert. Mehr Adressaten → größerer Kreis. '
'Quellen: DESTATIS Unternehmensregister, '
'IHK-/Handwerkskammer-Mitgliederzahlen.',
),
const SizedBox(height: ReclaimSpace.sm),
_LegendRow(
symbol: '',
axis: 'Tier-Farbe',
label: 'Evidenz-Stufe',
description:
'Petrol → warm-orange markiert die Stufe (T1T4) der '
'schwächsten Eingangs-Variable. Eine SKM-Zahl mit '
'T4-Schätzwert für h trägt die ganze Zahl als T4 '
'(Regel der niedrigsten Stufe, Studie §6.7).',
),
],
),
);
}
}
class _LegendRow extends StatelessWidget {
const _LegendRow({
required this.symbol,
required this.axis,
required this.label,
required this.description,
});
final String symbol;
final String axis;
final String label;
final String description;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 28,
child: Text(
symbol,
style: t.titleMedium?.copyWith(
color: ReclaimColors.signal,
fontSize: 18,
),
),
),
SizedBox(
width: 140,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
axis,
style: t.labelSmall?.copyWith(
color: ReclaimColors.mute,
letterSpacing: 0.4,
),
),
Text(
label,
style: t.titleMedium,
),
],
),
),
const SizedBox(width: ReclaimSpace.md),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
description,
style: t.bodyLarge?.copyWith(
color: ReclaimColors.mute,
height: 1.5,
),
),
),
),
],
);
}
}