feat(map): carte multi-localisations — un point par step géocodé

La carte clusterisait depuis bands.geom (un seul point par groupe). Désormais
/api/clusters source band_locations : un groupe multi-périodes (ex. Thessaloniki
puis Boston) apparaît comme plusieurs points distincts.

- migration 010: colonne geom générée (Point,4326) + index GIST sur
  band_locations (évite le seq-scan sur les requêtes bbox)
- /api/clusters: FROM band_locations bl JOIN bands b, bbox via geom && envelope,
  filtres (pays/statut/genre/année) sur b.*, points issus de bl.lat/lon ;
  la JSON des clusters porte location_raw + step_label
- web parseBandData: expose location_raw + step_label (base pour différencier
  plus tard un groupe qui a déménagé d'un groupe resté sur place)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nicolas Fryder 2026-07-02 19:52:40 +02:00
parent 3d3362fea0
commit 39e6b2a66f
3 changed files with 69 additions and 35 deletions

View file

@ -0,0 +1,16 @@
-- 010_band_locations_geom.sql
-- La carte doit clusteriser par LOCALISATION (band_locations), pas par groupe
-- (bands). band_locations n'avait que lat/lon sans index spatial → seq-scan à
-- chaque déplacement de carte. On ajoute une colonne geom générée + index GIST,
-- alignée sur le pattern de la table bands.
ALTER TABLE band_locations
ADD COLUMN IF NOT EXISTS geom geometry(Point, 4326)
GENERATED ALWAYS AS (
CASE
WHEN lat IS NOT NULL AND lon IS NOT NULL
THEN ST_SetSRID(ST_MakePoint(lon, lat), 4326)
END
) STORED;
CREATE INDEX IF NOT EXISTS idx_bl_geom ON band_locations USING GIST (geom);

View file

@ -251,12 +251,16 @@ fastify.get("/api/clusters", async (req, reply) => {
const cellSize = Math.max(0.01, 180 / Math.pow(2, zoomLevel));
const where = ["geom IS NOT NULL"];
// Source: band_locations (un point par localisation géocodée) JOIN bands.
const where = [
"bl.geom IS NOT NULL",
"bl.geocode_status IN ('done','country_only')",
];
const vals = [];
let i = 1;
where.push(`ST_X(geom::geometry) >= $${i} AND ST_X(geom::geometry) <= $${i+1} AND ST_Y(geom::geometry) >= $${i+2} AND ST_Y(geom::geometry) <= $${i+3}`);
vals.push(minLon, maxLon, minLat, maxLat);
where.push(`bl.geom && ST_MakeEnvelope($${i}, $${i+1}, $${i+2}, $${i+3}, 4326)`);
vals.push(minLon, minLat, maxLon, maxLat);
i += 4;
if (countries) {
@ -265,7 +269,7 @@ fastify.get("/api/clusters", async (req, reply) => {
return reply.code(400).send({ ok: false, error: "Max 100 pays simultanés" });
}
if (cs.length) {
where.push(`country = ANY($${i}::text[])`);
where.push(`b.country = ANY($${i}::text[])`);
vals.push(cs);
i++;
}
@ -277,7 +281,7 @@ fastify.get("/api/clusters", async (req, reply) => {
return reply.code(400).send({ ok: false, error: "Max 50 statuts simultanés" });
}
if (st.length) {
where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = ANY($${i}::text[])`);
where.push(`COALESCE(NULLIF(trim(b.status), ''), 'Unknown') = ANY($${i}::text[])`);
vals.push(st);
i++;
}
@ -286,7 +290,7 @@ fastify.get("/api/clusters", async (req, reply) => {
if (genre) {
try {
const genreQuery = sanitizeSearchString(genre, 'Genre');
where.push(`genre ILIKE $${i}`);
where.push(`b.genre ILIKE $${i}`);
vals.push(`%${genreQuery}%`);
i++;
} catch (err) {
@ -299,7 +303,7 @@ fastify.get("/api/clusters", async (req, reply) => {
if (!Number.isFinite(yearMin) || yearMin < 1800 || yearMin > 2100) {
return reply.code(400).send({ ok: false, error: 'year_min invalide' });
}
where.push(`formed_year >= $${i}`);
where.push(`b.formed_year >= $${i}`);
vals.push(yearMin);
i++;
}
@ -309,7 +313,7 @@ fastify.get("/api/clusters", async (req, reply) => {
if (!Number.isFinite(yearMax) || yearMax < 1800 || yearMax > 2100) {
return reply.code(400).send({ ok: false, error: 'year_max invalide' });
}
where.push(`formed_year <= $${i}`);
where.push(`b.formed_year <= $${i}`);
vals.push(yearMax);
i++;
}
@ -318,19 +322,24 @@ fastify.get("/api/clusters", async (req, reply) => {
if (zoomLevel >= 12) {
const sql = `
SELECT
ma_id,
name,
country,
COALESCE(NULLIF(trim(status), ''), 'Unknown') as status,
genre,
location_text,
formed_year,
lat,
lon
FROM bands
SELECT
b.ma_id,
b.name,
b.country,
COALESCE(NULLIF(trim(b.status), ''), 'Unknown') as status,
b.genre,
b.location_text,
b.formed_year,
bl.lat,
bl.lon,
bl.location_raw,
bl.step_order,
bl.step_label,
bl.is_country_only
FROM band_locations bl
JOIN bands b ON b.ma_id = bl.ma_id
WHERE ${whereSql}
ORDER BY ma_id ASC
ORDER BY bl.ma_id ASC, bl.step_order ASC
LIMIT 2000;
`;
const r = await p.query(sql, vals);
@ -344,23 +353,26 @@ fastify.get("/api/clusters", async (req, reply) => {
const sql = `
WITH grid_cells AS (
SELECT
floor(ST_X(geom::geometry) / $${i})::int as cell_x,
floor(ST_Y(geom::geometry) / $${i})::int as cell_y,
ma_id,
name,
country,
COALESCE(NULLIF(trim(status), ''), 'Unknown') as status,
genre,
location_text,
formed_year,
lat,
lon
FROM bands
SELECT
floor(ST_X(bl.geom) / $${i})::int as cell_x,
floor(ST_Y(bl.geom) / $${i})::int as cell_y,
b.ma_id,
b.name,
b.country,
COALESCE(NULLIF(trim(b.status), ''), 'Unknown') as status,
b.genre,
b.location_text,
b.formed_year,
bl.lat,
bl.lon,
bl.location_raw,
bl.step_label
FROM band_locations bl
JOIN bands b ON b.ma_id = bl.ma_id
WHERE ${whereSql}
),
clusters AS (
SELECT
SELECT
cell_x,
cell_y,
count(*)::int as band_count,
@ -375,7 +387,9 @@ fastify.get("/api/clusters", async (req, reply) => {
'location_text', location_text,
'formed_year', formed_year,
'lat', lat,
'lon', lon
'lon', lon,
'location_raw', location_raw,
'step_label', step_label
) ORDER BY name) as bands
FROM grid_cells
GROUP BY cell_x, cell_y

View file

@ -321,6 +321,10 @@ function parseBandData(x) {
formed_year: parseYear(x.formed_year ?? x.data?.formed_year ?? x.data?.formed ?? x.data?.formation_year ?? x.data?.year_formed),
lat: (x.lat == null ? null : Number(x.lat)),
lon: (x.lon == null ? null : Number(x.lon)),
// Localisation précise (band_locations) : présent quand le point vient
// d'un step géocodé plutôt que du point unique du groupe.
location_raw: x.location_raw != null ? norm(x.location_raw) : null,
step_label: x.step_label != null ? norm(x.step_label) : null,
data: x.data || {},
geocoded_at: x.geocoded_at || null,
};