feat(ui): collapsible legend gives the heatmap more vertical room

The legend below the heatmap used four full prose paragraphs at
all times — visually it claimed about a quarter of the heatmap
page's height while the plot itself was the actual artefact.
Now the legend defaults to a single-line header strip:

  Legende    ↔ Schaden    ↕ Nutzen    ● Betroffenheit
             ◐ Evidenz-Stufe                              ⌄

Tap anywhere on the strip and an AnimatedSize transition opens
the full _LegendRow descriptions (180 ms). Same content, hidden
until asked for. The Expanded(heatmap) above reclaims the
height — concretely on a 900 px window the plot grows from
~420 px to ~600 px tall, which is what makes hover-targeting
the smaller points actually comfortable.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-18 14:54:01 +02:00
parent 7c92268238
commit 94f4473686

View file

@ -129,58 +129,121 @@ class _HeatmapPageState extends State<HeatmapPage> {
}
}
/// 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 {
/// Axis + circle legend below the heatmap.
///
/// Collapsed default: one-line header row with mini-labels for
/// Schaden / Nutzen / Betroffenheit / Evidenz-Stufe + chevron
/// keeps the heatmap visually dominant and gives back vertical
/// space to the plot. Tap anywhere on the header expands the
/// full reading-instructions (axes, scale, sources, Tier rules).
class _LegendCard extends StatefulWidget {
const _LegendCard();
@override
State<_LegendCard> createState() => _LegendCardState();
}
class _LegendCardState extends State<_LegendCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return ReclaimCard(
padding: const EdgeInsets.symmetric(
horizontal: ReclaimSpace.lg,
vertical: ReclaimSpace.sm,
),
child: InkWell(
onTap: () => setState(() => _expanded = !_expanded),
borderRadius:
const BorderRadius.all(ReclaimRadius.sm),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: ReclaimSpace.xs,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Legende', style: t.headlineSmall),
const SizedBox(height: ReclaimSpace.md),
const _LegendRow(
Row(
children: [
Text('Legende',
style: t.titleMedium?.copyWith(
color: ReclaimColors.mute,
)),
const SizedBox(width: ReclaimSpace.lg),
const Expanded(
child: Wrap(
spacing: ReclaimSpace.lg,
runSpacing: ReclaimSpace.xs,
children: [
_MiniLabel(symbol: '', text: 'Schaden'),
_MiniLabel(symbol: '', text: 'Nutzen'),
_MiniLabel(
symbol: '', text: 'Betroffenheit'),
_MiniLabel(
symbol: '', text: 'Evidenz-Stufe'),
],
),
),
Icon(
_expanded
? Icons.expand_less
: Icons.expand_more,
color: ReclaimColors.mute,
),
],
),
AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
child: _expanded
? const Padding(
padding:
EdgeInsets.only(top: ReclaimSpace.lg),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
_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). '
'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),
const _LegendRow(
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 '
'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),
const _LegendRow(
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. '
'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),
const _LegendRow(
SizedBox(height: ReclaimSpace.sm),
_LegendRow(
symbol: '',
axis: 'Farb-Kante',
label: 'Evidenz-Stufe',
@ -190,13 +253,46 @@ class _LegendCard extends StatelessWidget {
'T2 Verband (DIHK, IHK, ZDH, BDI), '
'T3 eigene Erhebung, '
'T4 qualitatives Signal. '
'Eine Zahl bekommt die Stufe ihrer schwächsten '
'Eingangs-Variable — wenn h nur geschätzt ist (T4), '
'ist die ganze SKM-Zahl T4. Petrol → warm = stark → '
'Eine Zahl bekommt die Stufe ihrer '
'schwächsten Eingangs-Variable — wenn h '
'nur geschätzt ist (T4), ist die ganze '
'SKM-Zahl T4. Petrol → warm = stark → '
'schwach.',
),
],
),
)
: const SizedBox.shrink(),
),
],
),
),
),
);
}
}
class _MiniLabel extends StatelessWidget {
const _MiniLabel({required this.symbol, required this.text});
final String symbol;
final String text;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(symbol,
style: t.titleMedium?.copyWith(
color: ReclaimColors.signal,
)),
const SizedBox(width: 6),
Text(text, style: t.labelLarge),
],
);
}
}