217 lines
6.7 KiB
TypeScript
217 lines
6.7 KiB
TypeScript
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<T>(
|
|
concurrency: number,
|
|
items: T[],
|
|
fn: (item: T) => Promise<void>,
|
|
): Promise<void> {
|
|
const queue = [...items];
|
|
async function worker(): Promise<void> {
|
|
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<ComputeRoutingData>): Promise<void> {
|
|
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<string, Map<string, {
|
|
poiId: string;
|
|
poiName: string | null;
|
|
distM: number;
|
|
timeS: number | null;
|
|
}>>();
|
|
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<string, number>();
|
|
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<string, KnnRow[]>();
|
|
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()
|
|
`);
|
|
}
|
|
}
|