- 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>
271 lines
10 KiB
JavaScript
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>
|
|
);
|
|
}
|