Auger/lib/widgets/signal_probability_chart.dart

214 lines
7.1 KiB
Dart

import "dart:math";
import "package:capstone_project/models/event_signal.dart";
import "package:capstone_project/utils/signal_buckets.dart";
import "package:fl_chart/fl_chart.dart";
import "package:google_fonts/google_fonts.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
const Color _growthColor = Color(0xFF16a34a);
const Color _declineColor = Color(0xFFdc2626);
const Color _neutralColor = Color(0xFF888888);
// one day in milliseconds — used to convert between DateTime and the chart's x axis
const double _msPerDay = 1000 * 60 * 60 * 24;
// Scatter of signals: X is the signal date, Y is the impact, color is direction,
// opacity scales with probability (low-credibility signals fade into the back).
// No line connecting them — signals are discrete events, not a time series.
class SignalProbabilityChart extends StatelessWidget {
final List<EventSignal> signals;
final double height;
final bool compact;
const SignalProbabilityChart({
super.key,
required this.signals,
this.height = 200,
this.compact = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (signals.isEmpty) {
return Container(
width: double.infinity,
height: height,
decoration: BoxDecoration(
color: theme.colorScheme.secondary,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: theme.colorScheme.border, width: 1),
),
alignment: Alignment.center,
child: Text("No signal history yet").muted.small,
);
}
// sort so index ordering lines up with the spots list (used for tooltip lookup)
final sorted = [...signals]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
final xs = sorted.map((s) => _toDays(s.createdAt)).toList();
final rawMinX = xs.reduce(min);
final rawMaxX = xs.reduce(max);
// keep a minimum visual span so a single-date cluster doesn't collapse the axis
final span = (rawMaxX - rawMinX) < 1.0 ? 1.0 : (rawMaxX - rawMinX);
final xPad = span * 0.06;
final minX = rawMinX - xPad;
final maxX = rawMaxX + xPad;
final spots = <ScatterSpot>[];
for (final s in sorted) {
final base = _directionColor(s.direction);
// probability drives the alpha: 0.0 → 0.30, 1.0 → 0.95
final alpha = 0.30 + (s.probability.clamp(0.0, 1.0) * 0.65);
spots.add(
ScatterSpot(
_toDays(s.createdAt),
s.impact,
dotPainter: FlDotCirclePainter(
radius: 5,
color: base.withValues(alpha: alpha),
strokeWidth: 1,
strokeColor: theme.colorScheme.background,
),
),
);
}
final labelStyle = GoogleFonts.ibmPlexMono(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w500,
color: theme.colorScheme.mutedForeground,
);
return SizedBox(
width: double.infinity,
height: height,
child: ScatterChart(
ScatterChartData(
minX: minX,
maxX: maxX,
minY: 0,
maxY: 1,
scatterSpots: spots,
gridData: FlGridData(
show: !compact,
drawVerticalLine: false,
horizontalInterval: 0.25,
getDrawingHorizontalLine: (_) => FlLine(
color: theme.colorScheme.border,
strokeWidth: 1,
),
),
borderData: FlBorderData(
show: !compact,
border: Border.all(color: theme.colorScheme.border, width: 1),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: !compact,
reservedSize: 22,
interval: _xTickInterval(minX, maxX),
getTitlesWidget: (value, meta) {
final d = DateTime.fromMillisecondsSinceEpoch(
(value * _msPerDay).round(),
);
return Padding(
padding: const EdgeInsets.only(top: 4),
child: RichText(text: TextSpan(text: _shortDate(d), style: labelStyle)),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: !compact,
reservedSize: 42,
interval: 0.25,
getTitlesWidget: (value, meta) => RichText(
text: TextSpan(
text: "${(value * 100).round()}%",
style: labelStyle,
),
),
),
),
),
scatterTouchData: compact
? ScatterTouchData(enabled: false)
: ScatterTouchData(
enabled: true,
touchTooltipData: ScatterTouchTooltipData(
getTooltipColor: (_) => theme.colorScheme.background,
tooltipBorder: BorderSide(color: theme.colorScheme.border, width: 1),
tooltipPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
maxContentWidth: 220,
getTooltipItems: (ScatterSpot touched) {
// match back to the source signal by x+y — ScatterSpot equality
// isn't guaranteed to line up with our original instances
final idx = sorted.indexWhere((s) =>
_toDays(s.createdAt) == touched.x && s.impact == touched.y);
if (idx < 0) return null;
final s = sorted[idx];
return ScatterTooltipItem(
"${_shortDate(s.createdAt)}\n"
"impact ${(s.impact * 100).toStringAsFixed(0)}% · ${s.direction}\n"
"probability ${(s.probability * 100).toStringAsFixed(0)}%",
textStyle: GoogleFonts.ibmPlexMono(
fontSize: 11,
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
),
textAlign: TextAlign.left,
);
},
),
),
),
),
);
}
}
double _toDays(DateTime d) => d.millisecondsSinceEpoch / _msPerDay;
Color _directionColor(String direction) => switch (direction) {
"positive" => _growthColor,
"negative" => _declineColor,
_ => _neutralColor,
};
// pick a sensible x-axis tick interval (in days) based on the visible range
double _xTickInterval(double minX, double maxX) {
final days = (maxX - minX).abs();
if (days <= 7) return 1;
if (days <= 30) return 7;
if (days <= 90) return 14;
if (days <= 365) return 30;
return (days / 6).ceilToDouble();
}
String _shortDate(DateTime d) {
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
return "${months[d.month - 1]} ${d.day}";
}