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 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 = []; 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}"; }