231 lines
5.8 KiB
Dart
231 lines
5.8 KiB
Dart
import 'dart:math' as math;
|
|
import '../data/models/shot.dart';
|
|
|
|
class GroupingResult {
|
|
final double centerX; // Center of the group (relative 0-1)
|
|
final double centerY;
|
|
final double diameter; // Maximum spread diameter (relative 0-1)
|
|
final double standardDeviation; // Dispersion metric
|
|
final double meanRadius; // Average distance from center
|
|
final int shotCount;
|
|
|
|
GroupingResult({
|
|
required this.centerX,
|
|
required this.centerY,
|
|
required this.diameter,
|
|
required this.standardDeviation,
|
|
required this.meanRadius,
|
|
required this.shotCount,
|
|
});
|
|
|
|
/// Grouping quality rating (1-5 stars)
|
|
int get qualityRating {
|
|
// Based on standard deviation relative to typical target size
|
|
if (standardDeviation < 0.02) return 5;
|
|
if (standardDeviation < 0.04) return 4;
|
|
if (standardDeviation < 0.06) return 3;
|
|
if (standardDeviation < 0.10) return 2;
|
|
return 1;
|
|
}
|
|
|
|
String get qualityDescription {
|
|
switch (qualityRating) {
|
|
case 5:
|
|
return 'Excellent';
|
|
case 4:
|
|
return 'Tres bien';
|
|
case 3:
|
|
return 'Bien';
|
|
case 2:
|
|
return 'Moyen';
|
|
default:
|
|
return 'A ameliorer';
|
|
}
|
|
}
|
|
|
|
factory GroupingResult.empty() {
|
|
return GroupingResult(
|
|
centerX: 0.5,
|
|
centerY: 0.5,
|
|
diameter: 0,
|
|
standardDeviation: 0,
|
|
meanRadius: 0,
|
|
shotCount: 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
class GroupingAnalyzerService {
|
|
/// Analyze the grouping of a list of shots
|
|
GroupingResult analyzeGrouping(List<Shot> shots) {
|
|
if (shots.isEmpty) {
|
|
return GroupingResult.empty();
|
|
}
|
|
|
|
if (shots.length == 1) {
|
|
return GroupingResult(
|
|
centerX: shots.first.x,
|
|
centerY: shots.first.y,
|
|
diameter: 0,
|
|
standardDeviation: 0,
|
|
meanRadius: 0,
|
|
shotCount: 1,
|
|
);
|
|
}
|
|
|
|
// Calculate center of group (centroid)
|
|
double sumX = 0;
|
|
double sumY = 0;
|
|
|
|
for (final shot in shots) {
|
|
sumX += shot.x;
|
|
sumY += shot.y;
|
|
}
|
|
|
|
final centerX = sumX / shots.length;
|
|
final centerY = sumY / shots.length;
|
|
|
|
// Calculate distances from center
|
|
final distances = <double>[];
|
|
for (final shot in shots) {
|
|
final dx = shot.x - centerX;
|
|
final dy = shot.y - centerY;
|
|
distances.add(math.sqrt(dx * dx + dy * dy));
|
|
}
|
|
|
|
// Calculate mean radius
|
|
final meanRadius = distances.reduce((a, b) => a + b) / distances.length;
|
|
|
|
// Calculate standard deviation
|
|
double sumSquaredDiff = 0;
|
|
for (final distance in distances) {
|
|
sumSquaredDiff += math.pow(distance - meanRadius, 2);
|
|
}
|
|
final standardDeviation = math.sqrt(sumSquaredDiff / distances.length);
|
|
|
|
// Calculate maximum spread (diameter)
|
|
// Find the two points that are farthest apart
|
|
double maxDistance = 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 distance = math.sqrt(dx * dx + dy * dy);
|
|
if (distance > maxDistance) {
|
|
maxDistance = distance;
|
|
}
|
|
}
|
|
}
|
|
|
|
return GroupingResult(
|
|
centerX: centerX,
|
|
centerY: centerY,
|
|
diameter: maxDistance,
|
|
standardDeviation: standardDeviation,
|
|
meanRadius: meanRadius,
|
|
shotCount: shots.length,
|
|
);
|
|
}
|
|
|
|
/// Calculate offset from target center
|
|
(double, double) calculateOffset({
|
|
required double groupCenterX,
|
|
required double groupCenterY,
|
|
required double targetCenterX,
|
|
required double targetCenterY,
|
|
}) {
|
|
return (
|
|
groupCenterX - targetCenterX,
|
|
groupCenterY - targetCenterY,
|
|
);
|
|
}
|
|
|
|
/// Get directional offset description (e.g., "haut-gauche")
|
|
String getOffsetDescription(double offsetX, double offsetY) {
|
|
if (offsetX.abs() < 0.02 && offsetY.abs() < 0.02) {
|
|
return 'Centre';
|
|
}
|
|
|
|
String vertical = '';
|
|
String horizontal = '';
|
|
|
|
if (offsetY < -0.02) {
|
|
vertical = 'Haut';
|
|
} else if (offsetY > 0.02) {
|
|
vertical = 'Bas';
|
|
}
|
|
|
|
if (offsetX < -0.02) {
|
|
horizontal = 'Gauche';
|
|
} else if (offsetX > 0.02) {
|
|
horizontal = 'Droite';
|
|
}
|
|
|
|
if (vertical.isNotEmpty && horizontal.isNotEmpty) {
|
|
return '$vertical-$horizontal';
|
|
}
|
|
|
|
return vertical.isNotEmpty ? vertical : horizontal;
|
|
}
|
|
|
|
/// Analyze trend across multiple sessions
|
|
GroupingTrend analyzeTrend(List<GroupingResult> results) {
|
|
if (results.length < 2) {
|
|
return GroupingTrend(
|
|
improving: false,
|
|
averageDiameter: results.isEmpty ? 0 : results.first.diameter,
|
|
recentDiameter: results.isEmpty ? 0 : results.first.diameter,
|
|
improvementPercentage: 0,
|
|
);
|
|
}
|
|
|
|
// Calculate averages for first half vs second half
|
|
final midpoint = results.length ~/ 2;
|
|
final firstHalf = results.sublist(0, midpoint);
|
|
final secondHalf = results.sublist(midpoint);
|
|
|
|
double firstHalfAvg = 0;
|
|
for (final r in firstHalf) {
|
|
firstHalfAvg += r.diameter;
|
|
}
|
|
firstHalfAvg /= firstHalf.length;
|
|
|
|
double secondHalfAvg = 0;
|
|
for (final r in secondHalf) {
|
|
secondHalfAvg += r.diameter;
|
|
}
|
|
secondHalfAvg /= secondHalf.length;
|
|
|
|
// Overall average
|
|
double totalAvg = 0;
|
|
for (final r in results) {
|
|
totalAvg += r.diameter;
|
|
}
|
|
totalAvg /= results.length;
|
|
|
|
final improvement = ((firstHalfAvg - secondHalfAvg) / firstHalfAvg) * 100;
|
|
|
|
return GroupingTrend(
|
|
improving: secondHalfAvg < firstHalfAvg,
|
|
averageDiameter: totalAvg,
|
|
recentDiameter: secondHalfAvg,
|
|
improvementPercentage: improvement,
|
|
);
|
|
}
|
|
}
|
|
|
|
class GroupingTrend {
|
|
final bool improving;
|
|
final double averageDiameter;
|
|
final double recentDiameter;
|
|
final double improvementPercentage;
|
|
|
|
GroupingTrend({
|
|
required this.improving,
|
|
required this.averageDiameter,
|
|
required this.recentDiameter,
|
|
required this.improvementPercentage,
|
|
});
|
|
}
|