Files
radarsargasses/frontend/src/components/SpotPanel/SpotPanel.jsx
Gwadaking a607fd3c99 fix: Veille/ambre uniquement si sargasses à <100km, "banc" au lieu de "patch"
- scoreLabel/etaLabel/SpotMarker conditionnent Veille à distance<100km
  pour éviter l'alerte ambre sur les spots sans sargasses proches
- "patch" → "banc de sargasses" dans le SpotPanel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 02:25:51 -04:00

271 lines
10 KiB
JavaScript

import { useEffect, useState } from 'react';
import { api } from '../../api/client';
import usePushSubscription from '../../hooks/usePushSubscription';
import './SpotPanel.css';
function scoreLabel(value, level, distance) {
if (level === 'high') return 'Impact';
if (level === 'medium') return 'Risque';
if (value >= 15 && distance != null && distance < 100) return 'Veille';
return 'OK';
}
const TYPE_ICON = { beach: '🏖', port: '⚓', surf: '🏄', fishing: '🎣' };
const TREND_CONFIG = {
increasing: { icon: '📈', label: 'Situation en dégradation', cls: 'trend--bad' },
stable: { icon: '➡️', label: 'Situation stable', cls: 'trend--neutral' },
decreasing: { icon: '📉', label: 'Amélioration en cours', cls: 'trend--good' },
};
const ETA_BUFFER_KM = 5;
function computeEta(score, horizons) {
if (score?.distance != null && score.distance < ETA_BUFFER_KM) return 0;
for (const h of [6, 12, 24, 48]) {
const horizon = horizons?.[`h${h}`];
if (horizon?.distanceKm != null && horizon.distanceKm < ETA_BUFFER_KM) return h;
}
return null;
}
function etaLabel(etaHours, score, distance) {
if (etaHours === 0) return { icon: '🚨', label: 'Impact en cours', cls: 'eta--now' };
if (etaHours === 6) return { icon: '⚠️', label: 'Impact prévu dans ~6h', cls: 'eta--soon' };
if (etaHours === 12) return { icon: '⚠️', label: 'Impact prévu dans ~12h', cls: 'eta--soon' };
if (etaHours === 24) return { icon: '🕐', label: 'Impact prévu dans ~24h', cls: 'eta--later' };
if (etaHours === 48) return { icon: '🕐', label: 'Impact prévu dans ~48h', cls: 'eta--later' };
if ((score ?? 0) >= 15 && distance != null && distance < 100)
return { icon: '👁', label: 'Sargasses en surveillance', cls: 'eta--watch' };
return { icon: '✅', label: 'Aucun impact prévu (48h)', cls: 'eta--safe' };
}
const HORIZON_LABELS = { now: 'Maintenant', h6: '+6h', h12: '+12h', h24: '+24h', h48: '+48h' };
export default function SpotPanel({ spot, activeStep, onClose }) {
const [data, setData] = useState(undefined);
const [error, setError] = useState(null);
useEffect(() => {
if (!spot) return;
setData(undefined);
setError(null);
api.spots.score(spot.id)
.then(d => setData(d))
.catch(() => setError('Impossible de charger le score.'));
}, [spot?.id]);
if (!spot) return null;
// Score à afficher selon l'étape active
const displayScore = activeStep === 'now'
? data?.score
: (data?.horizons?.[activeStep] ? {
value: data.horizons[activeStep].score,
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;
return (
<div className="spot-panel">
<button className="spot-panel__close" onClick={onClose} aria-label="Fermer"></button>
<div className="spot-panel__header">
<span className="spot-panel__icon">{TYPE_ICON[spot.type] ?? '📍'}</span>
<div>
<h2 className="spot-panel__name">{spot.name}</h2>
<p className="spot-panel__region">{spot.region}</p>
</div>
</div>
<div className="spot-panel__horizon-label">
{HORIZON_LABELS[activeStep] ?? activeStep}
{activeStep !== 'now' && displayScore?.confidence != null && (
<span className="spot-panel__confidence">
Confiance : {Math.round(displayScore.confidence * 100)}%
</span>
)}
</div>
<div className="spot-panel__score">
{data === undefined && <p className="spot-panel__loading">Chargement</p>}
{error && <p className="spot-panel__error">{error}</p>}
{data !== undefined && !error && (
displayScore === null
? <p className="spot-panel__no-data">Aucune donnée pour cet horizon.</p>
: (
<>
{activeStep === 'now' && (() => {
const eta = etaLabel(computeEta(data?.score, data?.horizons), data?.score?.value, data?.score?.distance);
return (
<div className={`spot-panel__eta ${eta.cls}`}>
<span className="spot-panel__eta-icon">{eta.icon}</span>
<span className="spot-panel__eta-label">{eta.label}</span>
</div>
);
})()}
<div className={`spot-panel__gauge gauge--${lvl}`}>
<p className="spot-panel__gauge-header">Indice de risque</p>
<span className="spot-panel__gauge-label">{scoreLabel(displayScore.value, lvl, displayScore.distance)}</span>
<div className="spot-panel__gauge-track">
<div
className="spot-panel__gauge-fill"
style={{ width: `${displayScore.value}%` }}
/>
</div>
<p className="spot-panel__gauge-hint">{displayScore.value} / 100</p>
</div>
{activeStep === 'now' && displayScore.trend && (() => {
const t = TREND_CONFIG[displayScore.trend] ?? TREND_CONFIG.stable;
return (
<div className={`spot-panel__trend ${t.cls}`}>
<span className="spot-panel__trend-icon">{t.icon}</span>
<span className="spot-panel__trend-label">{t.label}</span>
</div>
);
})()}
<dl className="spot-panel__details">
{displayScore.distance != null && (
<><dt>Distance sargasses</dt><dd>{displayScore.distance} km</dd></>
)}
{activeStep === 'now' && displayScore.computedAt && (
<><dt>Calculé le</dt>
<dd>{new Date(displayScore.computedAt).toLocaleString('fr-FR')}</dd></>
)}
</dl>
<ScoreBreakdown breakdown={displayScore.breakdown} />
{activeStep === 'now' && <PatchList patches={data?.patches} />}
</>
)
)}
</div>
<FeedbackButton spot={spot} />
<PushButton spotId={spot.id} />
</div>
);
}
function PatchList({ patches }) {
if (!patches?.length) return null;
// Échelle visuelle : 100 km = barre pleine
const MAX_KM = 100;
return (
<div className="patch-list">
<p className="patch-list__title">
{patches.length} banc{patches.length > 1 ? 's' : ''} de sargasses détecté{patches.length > 1 ? 's' : ''}
</p>
{patches.map((p, i) => {
const pct = Math.min((p.distanceKm / MAX_KM) * 100, 100);
const sizeLabel = p.areaKm2 >= 10
? `${p.areaKm2} km²`
: `${p.areaKm2} km²`;
return (
<div key={i} className="patch-list__row">
<span className="patch-list__dist">{p.distanceKm} km</span>
<div className="patch-list__track">
<div className="patch-list__fill" style={{ width: `${pct}%` }} />
</div>
<span className="patch-list__area">{sizeLabel}</span>
</div>
);
})}
</div>
);
}
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);
const submit = async (hasSeaweed) => {
if (loading || sent) return;
setLoading(true);
if (spot.latitude != null && spot.longitude != null) {
await api.feedback.create({
lat: spot.latitude, lng: spot.longitude,
hasSeaweed, coastalPointId: spot.id,
}).catch(console.error);
}
setLoading(false);
setSent(true);
};
if (sent) return <p className="spot-panel__feedback-thanks">Merci pour votre retour !</p>;
return (
<div className="spot-panel__feedback">
<p>Vous êtes sur place ?</p>
<div className="spot-panel__feedback-btns">
<button onClick={() => submit(true)} disabled={loading}> Je vois des sargasses</button>
<button onClick={() => submit(false)} disabled={loading}> Pas de sargasses</button>
</div>
</div>
);
}
function PushButton({ spotId }) {
const { supported, subscribed, loading, error, toggle } = usePushSubscription(spotId);
if (!supported) return null;
return (
<div className="spot-panel__push">
<button
className={`spot-panel__push-btn ${subscribed ? 'spot-panel__push-btn--active' : ''}`}
onClick={toggle}
disabled={loading}
aria-pressed={subscribed}
>
{loading ? '…' : subscribed ? '🔔 Alertes activées' : '🔕 Activer les alertes'}
</button>
{error && <p className="spot-panel__push-error">{error}</p>}
</div>
);
}