Files
impact/lib/services/statistics_service.dart

391 lines
12 KiB
Dart

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<List<HeatZone>> 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 {
final double stdDevX;
final double stdDevY;
final double stdDevRadial;
final double stdDevScore;
final double meanX;
final double meanY;
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
class RegionalStats {
final Map<String, int> quadrantDistribution;
final Map<String, int> sectorDistribution;
final String dominantDirection;
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<Session> 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<Session> sessions, {
StatsPeriod period = StatsPeriod.all,
}) {
// Filter sessions by period
final filteredSessions = _filterByPeriod(sessions, period);
// Collect all shots from all analyses in all sessions
final allShots = <Shot>[];
for (final session in filteredSessions) {
for (final analysis in session.analyses) {
allShots.addAll(analysis.shots);
}
}
if (allShots.isEmpty) {
return _emptyStatistics(period, filteredSessions);
}
// Calculate basic stats
final totalShots = allShots.length;
final totalScore = allShots.fold<int>(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 (using relative center 0.5, 0.5)
final precision = _calculatePrecision(allShots, 0.5, 0.5);
// Calculate standard deviation
final stdDev = _calculateStdDev(allShots);
// Calculate regional distribution
final regional = _calculateRegional(allShots, 0.5, 0.5);
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<Session> _filterByPeriod(List<Session> 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<Shot> shots, {int gridSize = 5}) {
final grid = List.generate(gridSize, (_) => List.generate(gridSize, (_) => <Shot>[]));
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);
}
int maxCount = 0;
for (final row in grid) {
for (final cell in row) {
if (cell.length > maxCount) maxCount = cell.length;
}
}
final zones = <List<HeatZone>>[];
for (int row = 0; row < gridSize; row++) {
final rowZones = <HeatZone>[];
for (int col = 0; col < gridSize; col++) {
final cellShots = grid[row][col];
final avgScore = cellShots.isEmpty ? 0.0 : cellShots.fold<int>(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<Shot> shots, double centerX, double centerY) {
if (shots.isEmpty) {
return const PrecisionStats(avgDistanceFromCenter: 0, groupingDiameter: 0, precisionScore: 0, consistencyScore: 0);
}
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;
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;
}
}
double variance = 0;
for (final d in distances) {
variance += math.pow(d - avgDistance, 2);
}
final stdDevDist = math.sqrt(variance / distances.length);
final precisionScore = math.max(0, (1 - avgDistance * 2) * 100);
final consistencyScore = math.max(0, (1 - stdDevDist * 5) * 100);
return PrecisionStats(
avgDistanceFromCenter: avgDistance,
groupingDiameter: maxSpread,
precisionScore: precisionScore.toDouble().clamp(0.0, 100.0),
consistencyScore: consistencyScore.toDouble().clamp(0.0, 100.0),
);
}
/// Calculate standard deviation statistics
StdDevStats _calculateStdDev(List<Shot> shots) {
if (shots.isEmpty) {
return const StdDevStats(stdDevX: 0, stdDevY: 0, stdDevRadial: 0, stdDevScore: 0, meanX: 0.5, meanY: 0.5, meanScore: 0);
}
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;
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;
return StdDevStats(
stdDevX: math.sqrt(varianceX),
stdDevY: math.sqrt(varianceY),
stdDevRadial: math.sqrt(varianceX + varianceY),
stdDevScore: math.sqrt(varianceScore),
meanX: meanX,
meanY: meanY,
meanScore: meanScore,
);
}
/// Calculate regional distribution
RegionalStats _calculateRegional(List<Shot> shots, double centerX, double centerY) {
if (shots.isEmpty) {
return const RegionalStats(quadrantDistribution: {}, sectorDistribution: {}, dominantDirection: 'Centre', biasX: 0, biasY: 0);
}
final quadrants = <String, int>{'Haut-Gauche': 0, 'Haut-Droite': 0, 'Bas-Gauche': 0, 'Bas-Droite': 0};
final sectors = <String, int>{'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;
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;
}
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;
}
}
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: sumDx / shots.length,
biasY: sumDy / shots.length,
);
}
String _angleToSector(double angle) {
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<Session> 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,
);
}
}