import 'dart:math' as math; import '../data/models/session.dart'; import '../data/models/shot.dart'; /// Time period for filtering statistics enum StatsPeriod { session, // Single session week, // Last 7 days month, // Last 30 days all, // All time } /// Heat zone data for a region of the target class HeatZone { final int row; final int col; final int shotCount; final double intensity; // 0-1, normalized final double avgScore; const HeatZone({ required this.row, required this.col, required this.shotCount, required this.intensity, required this.avgScore, }); } /// Heat map grid for the target class HeatMap { final int gridSize; final List> zones; final int maxShotsInZone; final int totalShots; const HeatMap({ required this.gridSize, required this.zones, required this.maxShotsInZone, required this.totalShots, }); } /// Precision statistics class PrecisionStats { /// Average distance from target center (0-1 normalized) final double avgDistanceFromCenter; /// Grouping diameter (spread of shots) final double groupingDiameter; /// Precision score (0-100, higher = better) final double precisionScore; /// Consistency score based on standard deviation (0-100) final double consistencyScore; const PrecisionStats({ required this.avgDistanceFromCenter, required this.groupingDiameter, required this.precisionScore, required this.consistencyScore, }); } /// Standard deviation statistics class StdDevStats { /// Standard deviation of X positions final double stdDevX; /// Standard deviation of Y positions final double stdDevY; /// Combined standard deviation (radial) final double stdDevRadial; /// Standard deviation of scores final double stdDevScore; /// Mean X position final double meanX; /// Mean Y position final double meanY; /// Mean score final double meanScore; const StdDevStats({ required this.stdDevX, required this.stdDevY, required this.stdDevRadial, required this.stdDevScore, required this.meanX, required this.meanY, required this.meanScore, }); } /// Regional distribution (quadrants or sectors) class RegionalStats { /// Shot distribution by quadrant (top-left, top-right, bottom-left, bottom-right) final Map quadrantDistribution; /// Shot distribution by sector (N, NE, E, SE, S, SW, W, NW, Center) final Map sectorDistribution; /// Dominant direction (where most shots land) final String dominantDirection; /// Bias offset from center final double biasX; final double biasY; const RegionalStats({ required this.quadrantDistribution, required this.sectorDistribution, required this.dominantDirection, required this.biasX, required this.biasY, }); } /// Complete statistics result class SessionStatistics { final int totalShots; final int totalScore; final double avgScore; final int maxScore; final int minScore; final HeatMap heatMap; final PrecisionStats precision; final StdDevStats stdDev; final RegionalStats regional; final List sessions; final StatsPeriod period; const SessionStatistics({ required this.totalShots, required this.totalScore, required this.avgScore, required this.maxScore, required this.minScore, required this.heatMap, required this.precision, required this.stdDev, required this.regional, required this.sessions, required this.period, }); } /// Service for calculating shooting statistics class StatisticsService { /// Calculate statistics for given sessions SessionStatistics calculateStatistics( List sessions, { StatsPeriod period = StatsPeriod.all, double targetCenterX = 0.5, double targetCenterY = 0.5, }) { // Filter sessions by period final filteredSessions = _filterByPeriod(sessions, period); // Collect all shots final allShots = []; for (final session in filteredSessions) { allShots.addAll(session.shots); } if (allShots.isEmpty) { return _emptyStatistics(period, filteredSessions); } // Calculate basic stats final totalShots = allShots.length; final totalScore = allShots.fold(0, (sum, shot) => sum + shot.score); final avgScore = totalScore / totalShots; final maxScore = allShots.map((s) => s.score).reduce(math.max); final minScore = allShots.map((s) => s.score).reduce(math.min); // Calculate heat map final heatMap = _calculateHeatMap(allShots, gridSize: 5); // Calculate precision final precision = _calculatePrecision(allShots, targetCenterX, targetCenterY); // Calculate standard deviation final stdDev = _calculateStdDev(allShots); // Calculate regional distribution final regional = _calculateRegional(allShots, targetCenterX, targetCenterY); return SessionStatistics( totalShots: totalShots, totalScore: totalScore, avgScore: avgScore, maxScore: maxScore, minScore: minScore, heatMap: heatMap, precision: precision, stdDev: stdDev, regional: regional, sessions: filteredSessions, period: period, ); } /// Filter sessions by time period List _filterByPeriod(List sessions, StatsPeriod period) { if (period == StatsPeriod.all) return sessions; final now = DateTime.now(); final cutoff = switch (period) { StatsPeriod.session => now.subtract(const Duration(hours: 24)), StatsPeriod.week => now.subtract(const Duration(days: 7)), StatsPeriod.month => now.subtract(const Duration(days: 30)), StatsPeriod.all => DateTime(1970), }; return sessions.where((s) => s.createdAt.isAfter(cutoff)).toList(); } /// Calculate heat map HeatMap _calculateHeatMap(List shots, {int gridSize = 5}) { // Initialize grid final grid = List.generate( gridSize, (_) => List.generate(gridSize, (_) => []), ); // Assign shots to grid cells for (final shot in shots) { final col = (shot.x * gridSize).floor().clamp(0, gridSize - 1); final row = (shot.y * gridSize).floor().clamp(0, gridSize - 1); grid[row][col].add(shot); } // Find max count for normalization int maxCount = 0; for (final row in grid) { for (final cell in row) { if (cell.length > maxCount) maxCount = cell.length; } } // Create heat zones final zones = >[]; for (int row = 0; row < gridSize; row++) { final rowZones = []; for (int col = 0; col < gridSize; col++) { final cellShots = grid[row][col]; final avgScore = cellShots.isEmpty ? 0.0 : cellShots.fold(0, (sum, s) => sum + s.score) / cellShots.length; rowZones.add(HeatZone( row: row, col: col, shotCount: cellShots.length, intensity: maxCount > 0 ? cellShots.length / maxCount : 0, avgScore: avgScore, )); } zones.add(rowZones); } return HeatMap( gridSize: gridSize, zones: zones, maxShotsInZone: maxCount, totalShots: shots.length, ); } /// Calculate precision statistics PrecisionStats _calculatePrecision( List shots, double centerX, double centerY, ) { if (shots.isEmpty) { return const PrecisionStats( avgDistanceFromCenter: 0, groupingDiameter: 0, precisionScore: 0, consistencyScore: 0, ); } // Calculate distances from center final distances = shots.map((shot) { final dx = shot.x - centerX; final dy = shot.y - centerY; return math.sqrt(dx * dx + dy * dy); }).toList(); final avgDistance = distances.reduce((a, b) => a + b) / distances.length; // Calculate grouping (spread between shots) double maxSpread = 0; for (int i = 0; i < shots.length; i++) { for (int j = i + 1; j < shots.length; j++) { final dx = shots[i].x - shots[j].x; final dy = shots[i].y - shots[j].y; final dist = math.sqrt(dx * dx + dy * dy); if (dist > maxSpread) maxSpread = dist; } } // Calculate standard deviation of distances (consistency) final meanDist = avgDistance; double variance = 0; for (final d in distances) { variance += math.pow(d - meanDist, 2); } final stdDevDist = math.sqrt(variance / distances.length); // Precision score: based on average distance from center (0-100) // 0 distance = 100 score, 0.5 distance = 0 score final precisionScore = math.max(0, (1 - avgDistance * 2) * 100); // Consistency score: based on grouping tightness (0-100) // Lower spread = higher consistency final consistencyScore = math.max(0, (1 - stdDevDist * 5) * 100); return PrecisionStats( avgDistanceFromCenter: avgDistance.toDouble(), groupingDiameter: maxSpread.toDouble(), precisionScore: precisionScore.clamp(0.0, 100.0).toDouble(), consistencyScore: consistencyScore.clamp(0.0, 100.0).toDouble(), ); } /// Calculate standard deviation statistics StdDevStats _calculateStdDev(List shots) { if (shots.isEmpty) { return const StdDevStats( stdDevX: 0, stdDevY: 0, stdDevRadial: 0, stdDevScore: 0, meanX: 0.5, meanY: 0.5, meanScore: 0, ); } // Calculate means double sumX = 0, sumY = 0, sumScore = 0; for (final shot in shots) { sumX += shot.x; sumY += shot.y; sumScore += shot.score; } final meanX = sumX / shots.length; final meanY = sumY / shots.length; final meanScore = sumScore / shots.length; // Calculate variances double varianceX = 0, varianceY = 0, varianceScore = 0; for (final shot in shots) { varianceX += math.pow(shot.x - meanX, 2); varianceY += math.pow(shot.y - meanY, 2); varianceScore += math.pow(shot.score - meanScore, 2); } varianceX /= shots.length; varianceY /= shots.length; varianceScore /= shots.length; final stdDevX = math.sqrt(varianceX); final stdDevY = math.sqrt(varianceY); final stdDevScore = math.sqrt(varianceScore); // Radial standard deviation final stdDevRadial = math.sqrt(varianceX + varianceY); return StdDevStats( stdDevX: stdDevX, stdDevY: stdDevY, stdDevRadial: stdDevRadial, stdDevScore: stdDevScore, meanX: meanX, meanY: meanY, meanScore: meanScore, ); } /// Calculate regional distribution RegionalStats _calculateRegional( List shots, double centerX, double centerY, ) { if (shots.isEmpty) { return const RegionalStats( quadrantDistribution: {}, sectorDistribution: {}, dominantDirection: 'Centre', biasX: 0, biasY: 0, ); } // Quadrant distribution final quadrants = { 'Haut-Gauche': 0, 'Haut-Droite': 0, 'Bas-Gauche': 0, 'Bas-Droite': 0, }; // Sector distribution (8 sectors + center) final sectors = { 'N': 0, 'NE': 0, 'E': 0, 'SE': 0, 'S': 0, 'SO': 0, 'O': 0, 'NO': 0, 'Centre': 0, }; double sumDx = 0, sumDy = 0; for (final shot in shots) { final dx = shot.x - centerX; final dy = shot.y - centerY; sumDx += dx; sumDy += dy; // Quadrant if (dy < 0) { quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite'] = quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite']! + 1; } else { quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite'] = quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite']! + 1; } // Sector final distance = math.sqrt(dx * dx + dy * dy); if (distance < 0.1) { sectors['Centre'] = sectors['Centre']! + 1; } else { final angle = math.atan2(dy, dx) * 180 / math.pi; final sector = _angleToSector(angle); sectors[sector] = sectors[sector]! + 1; } } // Calculate bias final biasX = sumDx / shots.length; final biasY = sumDy / shots.length; // Find dominant direction String dominant = 'Centre'; int maxCount = 0; sectors.forEach((key, value) { if (value > maxCount) { maxCount = value; dominant = key; } }); return RegionalStats( quadrantDistribution: quadrants, sectorDistribution: sectors, dominantDirection: dominant, biasX: biasX, biasY: biasY, ); } String _angleToSector(double angle) { // Angle is in degrees, -180 to 180 // 0 = East, 90 = South, -90 = North, 180/-180 = West if (angle >= -22.5 && angle < 22.5) return 'E'; if (angle >= 22.5 && angle < 67.5) return 'SE'; if (angle >= 67.5 && angle < 112.5) return 'S'; if (angle >= 112.5 && angle < 157.5) return 'SO'; if (angle >= 157.5 || angle < -157.5) return 'O'; if (angle >= -157.5 && angle < -112.5) return 'NO'; if (angle >= -112.5 && angle < -67.5) return 'N'; if (angle >= -67.5 && angle < -22.5) return 'NE'; return 'Centre'; } SessionStatistics _emptyStatistics(StatsPeriod period, List sessions) { return SessionStatistics( totalShots: 0, totalScore: 0, avgScore: 0, maxScore: 0, minScore: 0, heatMap: const HeatMap( gridSize: 5, zones: [], maxShotsInZone: 0, totalShots: 0, ), precision: const PrecisionStats( avgDistanceFromCenter: 0, groupingDiameter: 0, precisionScore: 0, consistencyScore: 0, ), stdDev: const StdDevStats( stdDevX: 0, stdDevY: 0, stdDevRadial: 0, stdDevScore: 0, meanX: 0.5, meanY: 0.5, meanScore: 0, ), regional: const RegionalStats( quadrantDistribution: {}, sectorDistribution: {}, dominantDirection: 'Centre', biasX: 0, biasY: 0, ), sessions: sessions, period: period, ); } }