/// Widget de visualisation heat map. /// /// Affiche une carte de chaleur (brouillard) de la distribution des tirs /// avec gradient bleu (froid/peu de tirs) à rouge (chaud/beaucoup de tirs). /// Utilise un effet de brouillard radial pour un rendu fluide. library; import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import '../../../services/statistics_service.dart'; class HeatMapWidget extends StatelessWidget { final HeatMap heatMap; final double size; const HeatMapWidget({ super.key, required this.heatMap, this.size = 250, }); @override Widget build(BuildContext context) { if (heatMap.zones.isEmpty || heatMap.totalShots == 0) { return SizedBox( width: size, height: size, child: const Center( child: Text('Aucune donnee'), ), ); } return Container( width: size, height: size, decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(8), ), child: ClipRRect( borderRadius: BorderRadius.circular(7), child: CustomPaint( size: Size(size, size), painter: _HeatMapFogPainter(heatMap: heatMap), ), ), ); } } class _HeatMapFogPainter extends CustomPainter { final HeatMap heatMap; _HeatMapFogPainter({required this.heatMap}); @override void paint(Canvas canvas, Size size) { if (heatMap.zones.isEmpty) return; // Draw base background (cold blue) final bgPaint = Paint() ..color = const Color(0xFF1A237E).withValues(alpha: 0.3); // Dark blue base canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), bgPaint); final cellWidth = size.width / heatMap.gridSize; final cellHeight = size.height / heatMap.gridSize; // Collect all shot positions with their intensities for fog effect final hotSpots = <_HotSpot>[]; for (int row = 0; row < heatMap.gridSize; row++) { for (int col = 0; col < heatMap.gridSize; col++) { final zone = heatMap.zones[row][col]; if (zone.shotCount > 0) { hotSpots.add(_HotSpot( x: (col + 0.5) * cellWidth, y: (row + 0.5) * cellHeight, intensity: zone.intensity, shotCount: zone.shotCount, )); } } } // Draw fog effect using radial gradients for each hot spot for (final spot in hotSpots) { _drawFogSpot(canvas, size, spot, cellWidth, cellHeight); } // Draw target overlay (concentric circles) final center = Offset(size.width / 2, size.height / 2); final maxRadius = size.width / 2; final circlePaint = Paint() ..color = Colors.white.withValues(alpha: 0.5) ..style = PaintingStyle.stroke ..strokeWidth = 1; for (int i = 1; i <= 5; i++) { canvas.drawCircle(center, maxRadius * (i / 5), circlePaint); } // Draw crosshair canvas.drawLine( Offset(center.dx, 0), Offset(center.dx, size.height), circlePaint, ); canvas.drawLine( Offset(0, center.dy), Offset(size.width, center.dy), circlePaint, ); // Draw shot counts for (final spot in hotSpots) { final textPainter = TextPainter( text: TextSpan( text: '${spot.shotCount}', style: TextStyle( color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold, shadows: [ Shadow(color: Colors.black.withValues(alpha: 0.8), blurRadius: 4), Shadow(color: Colors.black.withValues(alpha: 0.8), blurRadius: 2), ], ), ), textDirection: TextDirection.ltr, ); textPainter.layout(); textPainter.paint( canvas, Offset(spot.x - textPainter.width / 2, spot.y - textPainter.height / 2), ); } } void _drawFogSpot(Canvas canvas, Size size, _HotSpot spot, double cellWidth, double cellHeight) { // Calculate fog radius based on intensity and cell size final baseRadius = math.max(cellWidth, cellHeight) * 1.5; final radius = baseRadius * (0.5 + spot.intensity * 0.5); // Create gradient from hot (red/orange) to transparent final gradient = ui.Gradient.radial( Offset(spot.x, spot.y), radius, [ _getHeatColor(spot.intensity).withValues(alpha: 0.7 * spot.intensity + 0.3), _getHeatColor(spot.intensity * 0.5).withValues(alpha: 0.3 * spot.intensity), Colors.transparent, ], [0.0, 0.5, 1.0], ); final paint = Paint() ..shader = gradient ..blendMode = BlendMode.screen; // Additive blending for fog effect canvas.drawCircle(Offset(spot.x, spot.y), radius, paint); // Add a second layer for more intensity if (spot.intensity > 0.3) { final innerGradient = ui.Gradient.radial( Offset(spot.x, spot.y), radius * 0.6, [ _getHeatColor(spot.intensity).withValues(alpha: 0.5 * spot.intensity), Colors.transparent, ], [0.0, 1.0], ); final innerPaint = Paint() ..shader = innerGradient ..blendMode = BlendMode.screen; canvas.drawCircle(Offset(spot.x, spot.y), radius * 0.6, innerPaint); } } Color _getHeatColor(double intensity) { // Gradient from blue (cold) to red (hot) if (intensity <= 0) return const Color(0xFF2196F3); // Blue if (intensity >= 1) return const Color(0xFFFF1744); // Red // Interpolate between blue -> cyan -> yellow -> orange -> red if (intensity < 0.25) { // Blue to Cyan return Color.lerp( const Color(0xFF2196F3), // Blue const Color(0xFF00BCD4), // Cyan intensity / 0.25, )!; } else if (intensity < 0.5) { // Cyan to Yellow return Color.lerp( const Color(0xFF00BCD4), // Cyan const Color(0xFFFFEB3B), // Yellow (intensity - 0.25) / 0.25, )!; } else if (intensity < 0.75) { // Yellow to Orange return Color.lerp( const Color(0xFFFFEB3B), // Yellow const Color(0xFFFF9800), // Orange (intensity - 0.5) / 0.25, )!; } else { // Orange to Red return Color.lerp( const Color(0xFFFF9800), // Orange const Color(0xFFFF1744), // Red (intensity - 0.75) / 0.25, )!; } } @override bool shouldRepaint(covariant _HeatMapFogPainter oldDelegate) { return heatMap != oldDelegate.heatMap; } } class _HotSpot { final double x; final double y; final double intensity; final int shotCount; _HotSpot({ required this.x, required this.y, required this.intensity, required this.shotCount, }); }