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:
parent
3d3362fea0
commit
39e6b2a66f
3 changed files with 69 additions and 35 deletions
16
apps/api/migrations/010_band_locations_geom.sql
Normal file
16
apps/api/migrations/010_band_locations_geom.sql
Normal 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);
|
||||
|
|
@ -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++;
|
||||
}
|
||||
|
|
@ -319,18 +323,23 @@ 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
|
||||
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);
|
||||
|
|
@ -345,18 +354,21 @@ 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
|
||||
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 (
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue