214 lines
7.1 KiB
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}";
|
|
}
|