diff --git a/backend/src/Controller/SpotScoreController.php b/backend/src/Controller/SpotScoreController.php index e6c7976..2106b54 100644 --- a/backend/src/Controller/SpotScoreController.php +++ b/backend/src/Controller/SpotScoreController.php @@ -152,12 +152,20 @@ class SpotScoreController extends AbstractController $raw = $distanceScore + $densityScore + $velocityScore + $trendScore; $score = max(0, min(100, (int) round($raw * $confidence))); + $cf = fn(float $v): int => (int) round($v * $confidence); + $horizons['h' . $horizon] = [ 'score' => $score, 'level' => $this->scoreToLevel($score), 'distanceKm' => $distKm, 'confidence' => $confidence, 'validAt' => $row['valid_at'], + 'breakdown' => [ + 'distance' => $cf($distanceScore), + 'density' => $cf($densityScore), + 'velocity' => $cf($velocityScore), + 'trend' => $cf($trendScore), + ], ]; $prevDistKm = $distKm; @@ -169,18 +177,33 @@ class SpotScoreController extends AbstractController /** * @param array $row - * @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, etaHours: int|null, computedAt: string} + * @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, etaHours: int|null, computedAt: string, breakdown: array{distance: int, density: int, velocity: int, trend: int}|null} */ private function formatScore(array $row): array { + $distKm = $row['distance'] !== null ? (float) $row['distance'] : null; + $densKm2 = $row['density'] !== null ? (float) $row['density'] : null; + $trend = $row['trend']; + $total = (int) $row['score']; + + $breakdown = null; + if ($distKm !== null && $densKm2 !== null && $trend !== null) { + $dScore = (int) round(max(0.0, 1.0 - $distKm * 1000 / 50_000) * 40); + $nScore = (int) round(min($densKm2 / 100, 1.0) * 30); + $tScore = match ($trend) { 'increasing' => 10, 'stable' => 5, default => 0 }; + $vScore = max(0, $total - $dScore - $nScore - $tScore); + $breakdown = ['distance' => $dScore, 'density' => $nScore, 'velocity' => $vScore, 'trend' => $tScore]; + } + return [ - 'value' => (int) $row['score'], + 'value' => $total, 'level' => $row['level'], - 'distance' => $row['distance'] !== null ? (float) $row['distance'] : null, - 'density' => $row['density'] !== null ? (float) $row['density'] : null, - 'trend' => $row['trend'], + 'distance' => $distKm, + 'density' => $densKm2, + 'trend' => $trend, 'etaHours' => $row['eta_hours'] !== null ? (int) $row['eta_hours'] : null, 'computedAt' => $row['computed_at'], + 'breakdown' => $breakdown, ]; } diff --git a/frontend/src/components/SpotPanel/SpotPanel.css b/frontend/src/components/SpotPanel/SpotPanel.css index 5e9c467..d94ca99 100644 --- a/frontend/src/components/SpotPanel/SpotPanel.css +++ b/frontend/src/components/SpotPanel/SpotPanel.css @@ -180,6 +180,59 @@ .spot-panel__error { font-size: 14px; text-align: center; padding: 12px 0; color: #777; } .spot-panel__error { color: #dc2626; } +/* Score breakdown */ +.score-breakdown { + border-top: 1px solid rgba(0,0,0,0.08); + padding-top: 10px; + margin-top: 8px; +} + +.score-breakdown__title { + margin: 0 0 8px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .07em; + color: #999; +} + +.score-breakdown__row { + display: grid; + grid-template-columns: 16px 58px 1fr 38px; + align-items: center; + gap: 6px; + margin-bottom: 5px; +} + +.score-breakdown__icon { font-size: 12px; text-align: center; line-height: 1; } +.score-breakdown__label { font-size: 12px; color: #666; white-space: nowrap; } + +.score-breakdown__track { + height: 5px; + background: rgba(0,0,0,0.08); + border-radius: 3px; + overflow: hidden; +} + +.score-breakdown__fill { + height: 100%; + background: #f4a020; + border-radius: 3px; + transition: width .5s ease; +} + +.score-breakdown__value { + text-align: right; + font-size: 11px; + font-weight: 600; + color: #1a1a2e; +} + +.score-breakdown__max { + color: #aaa; + font-weight: 400; +} + /* Feedback */ .spot-panel__feedback { border-top: 2px solid #e5e7eb; diff --git a/frontend/src/components/SpotPanel/SpotPanel.jsx b/frontend/src/components/SpotPanel/SpotPanel.jsx index 7b66193..f518f82 100644 --- a/frontend/src/components/SpotPanel/SpotPanel.jsx +++ b/frontend/src/components/SpotPanel/SpotPanel.jsx @@ -58,6 +58,7 @@ export default function SpotPanel({ spot, activeStep, onClose }) { level: data.horizons[activeStep].level, distance: data.horizons[activeStep].distanceKm, confidence: data.horizons[activeStep].confidence, + breakdown: data.horizons[activeStep].breakdown, } : null); const lvl = displayScore?.level; @@ -137,6 +138,8 @@ export default function SpotPanel({ spot, activeStep, onClose }) {
{new Date(displayScore.computedAt).toLocaleString('fr-FR')}
)} + + ) )} @@ -148,6 +151,39 @@ export default function SpotPanel({ spot, activeStep, onClose }) { ); } +const BREAKDOWN_ITEMS = [ + { key: 'distance', label: 'Distance', max: 40, icon: '📏' }, + { key: 'density', label: 'Densité', max: 30, icon: '🌿' }, + { key: 'velocity', label: 'Vitesse', max: 20, icon: '➡' }, + { key: 'trend', label: 'Tendance', max: 10, icon: '📈' }, +]; + +function ScoreBreakdown({ breakdown }) { + if (!breakdown) return null; + + return ( +
+

Détail du score

+ {BREAKDOWN_ITEMS.map(({ key, label, max, icon }) => { + const val = breakdown[key] ?? 0; + const pct = max > 0 ? (val / max) * 100 : 0; + return ( +
+ {icon} + {label} +
+
+
+ + {val}/{max} + +
+ ); + })} +
+ ); +} + function FeedbackButton({ spot }) { const [sent, setSent] = useState(false); const [loading, setLoading] = useState(false);