diff --git a/apps/api/migrations/010_band_locations_geom.sql b/apps/api/migrations/010_band_locations_geom.sql new file mode 100644 index 0000000..b4d9dfa --- /dev/null +++ b/apps/api/migrations/010_band_locations_geom.sql @@ -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); diff --git a/apps/api/src/server.js b/apps/api/src/server.js index 0ad2ad9..33fa6e9 100644 --- a/apps/api/src/server.js +++ b/apps/api/src/server.js @@ -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 diff --git a/apps/web/site/app.js b/apps/web/site/app.js index 97429d5..d6645d8 100644 --- a/apps/web/site/app.js +++ b/apps/web/site/app.js @@ -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, };