import type { Job } from "bullmq"; import { getSql } from "../db.js"; import type { JobProgress } from "@transportationer/shared"; import { fetchMatrix } from "../valhalla.js"; export type ComputeRoutingData = { type: "compute-routing"; citySlug: string; mode: "walking" | "cycling" | "driving"; category: string; }; /** Number of nearest POI candidates per grid point. */ const K = 6; /** Grid points per Valhalla matrix call. */ const BATCH_SIZE = 20; /** Concurrent Valhalla calls within this job. */ const BATCH_CONCURRENCY = 4; /** Rows per INSERT. */ const INSERT_CHUNK = 2000; async function asyncPool( concurrency: number, items: T[], fn: (item: T) => Promise, ): Promise { const queue = [...items]; async function worker(): Promise { while (queue.length > 0) await fn(queue.shift()!); } await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker)); } /** * Fetch Valhalla routing times from every grid point to the K nearest POIs * in the given category, then persist nearest-per-subcategory into grid_poi_details. */ export async function handleComputeRouting(job: Job): Promise { const { citySlug, mode, category } = job.data; const sql = getSql(); const gridPoints = await Promise.resolve(sql<{ id: string; lat: number; lng: number }[]>` SELECT id::text AS id, ST_Y(geom) AS lat, ST_X(geom) AS lng FROM grid_points WHERE city_slug = ${citySlug} ORDER BY id `); if (gridPoints.length === 0) return; const [{ count }] = await Promise.resolve(sql<{ count: number }[]>` SELECT COUNT(*)::int AS count FROM raw_pois WHERE city_slug = ${citySlug} AND category = ${category} `); if (count === 0) return; // Nearest POI per (gridPointId, subcategory). const result = new Map>(); for (const gp of gridPoints) result.set(gp.id, new Map()); const batches: Array<{ id: string; lat: number; lng: number }[]> = []; for (let i = 0; i < gridPoints.length; i += BATCH_SIZE) { batches.push(gridPoints.slice(i, i + BATCH_SIZE)); } let batchesDone = 0; await asyncPool(BATCH_CONCURRENCY, batches, async (batch) => { const batchIds = batch.map((gp) => gp.id); const knnRows = await Promise.resolve(sql<{ grid_point_id: string; poi_id: string; poi_lat: number; poi_lng: number; poi_name: string | null; dist_m: number; subcategory: string; }[]>` SELECT gp.id::text AS grid_point_id, p.osm_id::text AS poi_id, ST_Y(p.geom) AS poi_lat, ST_X(p.geom) AS poi_lng, p.poi_name, ST_Distance(gp.geom::geography, p.geom::geography) AS dist_m, p.subcategory FROM grid_points gp CROSS JOIN LATERAL ( SELECT p.osm_id, p.geom, p.subcategory, p.name AS poi_name FROM raw_pois p WHERE p.city_slug = ${citySlug} AND p.category = ${category} ORDER BY gp.geom <-> p.geom LIMIT ${K} ) p WHERE gp.id = ANY(${batchIds}::bigint[]) ORDER BY gp.id, dist_m `); batchesDone++; if (knnRows.length === 0) return; const targetIdx = new Map(); const targets: { lat: number; lng: number }[] = []; for (const row of knnRows) { if (!targetIdx.has(row.poi_id)) { targetIdx.set(row.poi_id, targets.length); targets.push({ lat: row.poi_lat, lng: row.poi_lng }); } } const sources = batch.map((gp) => ({ lat: gp.lat, lng: gp.lng })); let matrix: (number | null)[][]; try { matrix = await fetchMatrix(sources, targets, mode); } catch (err) { console.error( `[compute-routing] Valhalla failed (${mode}/${category}, batch ${batchesDone}):`, (err as Error).message, ); return; } type KnnRow = (typeof knnRows)[number]; const gpKnn = new Map(); for (const row of knnRows) { const list = gpKnn.get(row.grid_point_id) ?? []; list.push(row); gpKnn.set(row.grid_point_id, list); } for (let bi = 0; bi < batch.length; bi++) { const gp = batch[bi]; const knn = gpKnn.get(gp.id); if (!knn || knn.length === 0) continue; const subcatMap = result.get(gp.id)!; for (const row of knn) { if (!subcatMap.has(row.subcategory)) { const idx = targetIdx.get(row.poi_id); subcatMap.set(row.subcategory, { poiId: row.poi_id, poiName: row.poi_name, distM: row.dist_m, timeS: idx !== undefined ? (matrix[bi]?.[idx] ?? null) : null, }); } } } await job.updateProgress({ stage: `Routing ${mode}/${category}`, pct: Math.round((batchesDone / batches.length) * 100), message: `Batch ${batchesDone}/${batches.length}`, } satisfies JobProgress); }); // Bulk-insert nearest POI per subcategory into grid_poi_details. const gpIdArr: string[] = []; const subcatArr: string[] = []; const poiIdArr: (string | null)[] = []; const poiNameArr: (string | null)[] = []; const distArr: (number | null)[] = []; const timeArr: (number | null)[] = []; for (const [gpId, subcatMap] of result) { for (const [subcategory, detail] of subcatMap) { gpIdArr.push(gpId); subcatArr.push(subcategory); poiIdArr.push(detail.poiId); poiNameArr.push(detail.poiName); distArr.push(detail.distM); timeArr.push(detail.timeS); } } for (let i = 0; i < gpIdArr.length; i += INSERT_CHUNK) { const end = Math.min(i + INSERT_CHUNK, gpIdArr.length); await Promise.resolve(sql` INSERT INTO grid_poi_details ( grid_point_id, category, subcategory, travel_mode, nearest_poi_id, nearest_poi_name, distance_m, travel_time_s ) SELECT gp_id::bigint, ${category}, subcat, ${mode}, CASE WHEN poi_id IS NULL THEN NULL ELSE poi_id::bigint END, poi_name, dist, time_s FROM unnest( ${gpIdArr.slice(i, end)}::text[], ${subcatArr.slice(i, end)}::text[], ${poiIdArr.slice(i, end)}::text[], ${poiNameArr.slice(i, end)}::text[], ${distArr.slice(i, end)}::float8[], ${timeArr.slice(i, end)}::float8[] ) AS t(gp_id, subcat, poi_id, poi_name, dist, time_s) ON CONFLICT (grid_point_id, category, subcategory, travel_mode) DO UPDATE SET nearest_poi_id = EXCLUDED.nearest_poi_id, nearest_poi_name = EXCLUDED.nearest_poi_name, distance_m = EXCLUDED.distance_m, travel_time_s = EXCLUDED.travel_time_s, computed_at = now() `); } }