feat: explainability — décomposition des 4 composantes du score dans le SpotPanel

Expose breakdown {distance, density, velocity, trend} dans l'API /spots/{id}/score
(pour le score courant reconstruit depuis les champs stockés, et pour chaque horizon
avec confidence appliquée). Affiche les barres avec valeur/max dans le SpotPanel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-03 23:33:07 -04:00
parent 779c07ced0
commit d4d2314f7b
3 changed files with 117 additions and 5 deletions

View File

@@ -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;

View File

@@ -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 }) {
<dd>{new Date(displayScore.computedAt).toLocaleString('fr-FR')}</dd></>
)}
</dl>
<ScoreBreakdown breakdown={displayScore.breakdown} />
</>
)
)}
@@ -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 (
<div className="score-breakdown">
<p className="score-breakdown__title">Détail du score</p>
{BREAKDOWN_ITEMS.map(({ key, label, max, icon }) => {
const val = breakdown[key] ?? 0;
const pct = max > 0 ? (val / max) * 100 : 0;
return (
<div key={key} className="score-breakdown__row">
<span className="score-breakdown__icon">{icon}</span>
<span className="score-breakdown__label">{label}</span>
<div className="score-breakdown__track">
<div className="score-breakdown__fill" style={{ width: `${pct}%` }} />
</div>
<span className="score-breakdown__value">
{val}<span className="score-breakdown__max">/{max}</span>
</span>
</div>
);
})}
</div>
);
}
function FeedbackButton({ spot }) {
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);