perf: spots/overview — 100+ requêtes individuelles → 1 requête cachée 30 min

Avant : SargassesMap faisait 1 appel /api/spots (API Platform) puis
N appels /api/spots/{id}/score (un par spot, ~100+ pour la Guadeloupe).

Après : un seul GET /api/spots/overview (LATERAL JOIN spots+scores)
avec Cache-Control: public, max-age=1800, stale-while-revalidate=300.

Les marqueurs colorés s'affichent immédiatement depuis la réponse unique ;
/api/spots/{id}/score n'est appelé qu'au clic sur un spot (SpotPanel).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-14 12:43:38 -04:00
parent 14d2acc39b
commit 83690b94ab
3 changed files with 63 additions and 22 deletions

View File

@@ -20,6 +20,46 @@ class SpotScoreController extends AbstractController
private Connection $connection, private Connection $connection,
) {} ) {}
/**
* GET /api/spots/overview
*
* Retourne tous les spots avec leur dernier score en UNE seule requête.
* Remplace les N appels individuels à /api/spots/{id}/score pour le rendu
* initial de la carte.
*
* Cache HTTP 30 min (scores mis à jour au max 1-2×/jour par ingestion).
*/
#[Route('/overview', name: 'overview', methods: ['GET'])]
public function overview(): JsonResponse
{
$rows = $this->connection->fetchAllAssociative(
"SELECT
cp.id::text,
cp.name,
cp.type,
cp.region,
ST_X(cp.geometry::geometry) AS longitude,
ST_Y(cp.geometry::geometry) AS latitude,
ist.score,
ist.level,
ROUND(CAST(ist.distance_to_nearest_sargassum AS numeric), 2) AS distance_km
FROM coastal_point cp
LEFT JOIN LATERAL (
SELECT score, level, distance_to_nearest_sargassum
FROM impact_score
WHERE coastal_point_id = cp.id
ORDER BY timestamp DESC
LIMIT 1
) ist ON true
ORDER BY cp.name"
);
$response = $this->json($rows);
$response->headers->set('Cache-Control', 'public, max-age=1800, stale-while-revalidate=300');
return $response;
}
/** /**
* GET /api/spots/{id}/score * GET /api/spots/{id}/score
* *

View File

@@ -25,7 +25,7 @@ async function post(path, body) {
export const api = { export const api = {
spots: { spots: {
list: () => get('/spots'), overview: () => get('/spots/overview'), // tous les spots + score en 1 requête cachée
get: (id) => get(`/spots/${id}`), get: (id) => get(`/spots/${id}`),
score: (id, at) => get(`/spots/${id}/score`, { at }), score: (id, at) => get(`/spots/${id}/score`, { at }),
}, },

View File

@@ -179,8 +179,9 @@ function SpotMarker({ level, scoreValue, scoreDist, isSelected, name }) {
export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep, flyToTarget }) { export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep, flyToTarget }) {
const mapRef = useRef(null); const mapRef = useRef(null);
// spots : [{ id, name, type, region, longitude, latitude, score, level, distance_km }]
const [spots, setSpots] = useState([]); const [spots, setSpots] = useState([]);
const [spotScores, setSpotScores] = useState({}); const [spotScores, setSpotScores] = useState({}); // id → { level, value, distance }
const [viewState, setViewState] = useState(INITIAL_VIEW); const [viewState, setViewState] = useState(INITIAL_VIEW);
const [layerOpacity, setLayerOpacity] = useState(1); const [layerOpacity, setLayerOpacity] = useState(1);
const [mapStyle, setMapStyle] = useState(null); // null = style not yet ready const [mapStyle, setMapStyle] = useState(null); // null = style not yet ready
@@ -212,28 +213,28 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
return () => ctrl.abort(); return () => ctrl.abort();
}, []); }, []);
// Load coastal spots once // Charge tous les spots + scores en UNE requête (cachée 30 min côté serveur)
useEffect(() => { useEffect(() => {
api.spots.list() api.spots.overview()
.then(data => setSpots(Array.isArray(data) ? data : (data['hydra:member'] ?? data.member ?? []))) .then(rows => {
if (!Array.isArray(rows)) return;
setSpots(rows);
// Pré-remplir spotScores depuis la même réponse — aucune requête supplémentaire
const scores = {};
rows.forEach(r => {
if (r.level) {
scores[r.id] = {
level: r.level,
value: r.score ?? 0,
distance: r.distance_km ?? null,
};
}
});
setSpotScores(scores);
})
.catch(console.error); .catch(console.error);
}, []); }, []);
// Load scores progressively for marker colors
useEffect(() => {
if (!spots.length) return;
spots.forEach(s => {
api.spots.score(s.id)
.then(d => {
const level = d?.score?.level;
const value = d?.score?.value ?? 0;
const distance = d?.score?.distance ?? null;
if (level) setSpotScores(prev => ({ ...prev, [s.id]: { level, value, distance } }));
})
.catch(() => {});
});
}, [spots]);
// Fade transition on step change // Fade transition on step change
useEffect(() => { useEffect(() => {
setLayerOpacity(0); setLayerOpacity(0);