532 lines
14 KiB
Dart
532 lines
14 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 {
|
|
/// 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<String, int> quadrantDistribution;
|
|
|
|
/// Shot distribution by sector (N, NE, E, SE, S, SW, W, NW, Center)
|
|
final Map<String, int> 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<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,
|
|
double targetCenterX = 0.5,
|
|
double targetCenterY = 0.5,
|
|
}) {
|
|
// Filter sessions by period
|
|
final filteredSessions = _filterByPeriod(sessions, period);
|
|
|
|
// Collect all shots
|
|
final allShots = <Shot>[];
|
|
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<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
|
|
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<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}) {
|
|
// Initialize grid
|
|
final grid = List.generate(
|
|
gridSize,
|
|
(_) => List.generate(gridSize, (_) => <Shot>[]),
|
|
);
|
|
|
|
// 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 = <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,
|
|
);
|
|
}
|
|
|
|
// 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<Shot> 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<Shot> shots,
|
|
double centerX,
|
|
double centerY,
|
|
) {
|
|
if (shots.isEmpty) {
|
|
return const RegionalStats(
|
|
quadrantDistribution: {},
|
|
sectorDistribution: {},
|
|
dominantDirection: 'Centre',
|
|
biasX: 0,
|
|
biasY: 0,
|
|
);
|
|
}
|
|
|
|
// Quadrant distribution
|
|
final quadrants = <String, int>{
|
|
'Haut-Gauche': 0,
|
|
'Haut-Droite': 0,
|
|
'Bas-Gauche': 0,
|
|
'Bas-Droite': 0,
|
|
};
|
|
|
|
// Sector distribution (8 sectors + center)
|
|
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;
|
|
|
|
// 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<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,
|
|
);
|
|
}
|
|
}
|