From 830b498485df81287700bf67b023ddeedcdaf118 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Tue, 14 Apr 2026 13:28:19 +0300 Subject: [PATCH] 2026-04-14-2 full --- .../administrative-linking.service.ts | 115 ++++++++---------- .../entities/neighborhood-point.entity.ts | 4 + .../entities/neighborhood-polygon.entity.ts | 4 + .../api/src/geocoding/geocoding.controller.ts | 14 ++- apps/api/src/geocoding/geocoding.module.ts | 1 + apps/api/src/geocoding/geocoding.service.ts | 52 +++++--- apps/api/src/maps/maps.module.ts | 2 + apps/api/src/maps/maps.service.ts | 29 ++++- 8 files changed, 138 insertions(+), 83 deletions(-) diff --git a/apps/api/src/geocoding/administrative-linking.service.ts b/apps/api/src/geocoding/administrative-linking.service.ts index 63406a0..fdd140e 100644 --- a/apps/api/src/geocoding/administrative-linking.service.ts +++ b/apps/api/src/geocoding/administrative-linking.service.ts @@ -49,10 +49,10 @@ export class AdministrativeLinkingService { } /** - * Step 1: Sync Neighborhood Points from OSM for a given BBox + * Step 1: Sync Neighborhood Points from OSM for a given BBox and Country */ - async syncOsmNeighborhoodPoints(bbox: string = '31.86,35.94,32.22,36.25') { - this.logger.log(`Syncing neighborhood points for bbox: ${bbox}`); + async syncOsmNeighborhoodPoints(bbox: string = '31.86,35.94,32.22,36.25', country?: string) { + this.logger.log(`Syncing neighborhood points for country: ${country || 'auto'}, bbox: ${bbox}`); const query = `[out:json][timeout:900];node["place"~"neighbourhood|suburb|town"](${bbox});out;`; try { @@ -60,23 +60,33 @@ export class AdministrativeLinkingService { const elements = data.elements; this.logger.log(`Found ${elements.length} points in OSM.`); + // Determine country from BBox if not provided (simple heuristic) + let detectedCountry = country; + if (!detectedCountry) { + const firstLat = parseFloat(bbox.split(',')[0]); + if (firstLat > 32.3) detectedCountry = 'syria'; + else if (firstLat < 31.0) detectedCountry = 'egypt'; + else detectedCountry = 'jordan'; + } + for (const e of elements) { const nameAr = e.tags['name:ar'] || e.tags['name']; const nameEn = e.tags['name:en'] || e.tags['name']; const geometry = { type: 'Point', coordinates: [e.lon, e.lat] }; await this.dataSource.query(` - INSERT INTO neighborhood_points (osm_id, name_ar, name_en, place_type, geometry) - VALUES ($1, $2, $3, $4, ST_SetSRID(ST_GeomFromGeoJSON($5), 4326)) + INSERT INTO neighborhood_points (osm_id, name_ar, name_en, place_type, geometry, country) + VALUES ($1, $2, $3, $4, ST_SetSRID(ST_GeomFromGeoJSON($5), 4326), $6) ON CONFLICT (osm_id) DO UPDATE SET name_ar = EXCLUDED.name_ar, name_en = EXCLUDED.name_en, place_type = EXCLUDED.place_type, - geometry = EXCLUDED.geometry; - `, [e.id, nameAr, nameEn, e.tags['place'], JSON.stringify(geometry)]); + geometry = EXCLUDED.geometry, + country = COALESCE(EXCLUDED.country, neighborhood_points.country); + `, [e.id, nameAr, nameEn, e.tags['place'], JSON.stringify(geometry), detectedCountry]); } - // Link points to parent districts using containment or nearest neighbor + // Link points to parent districts await this.dataSource.query(` UPDATE neighborhood_points np SET district_id = COALESCE( @@ -94,21 +104,20 @@ export class AdministrativeLinkingService { LIMIT 1 ) ) - WHERE district_id IS NULL; - `); + WHERE district_id IS NULL AND (country = $1 OR $1 IS NULL); + `, [detectedCountry]); // Diagnostics const totalPoints = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_points`); const linkedPoints = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_points WHERE district_id IS NOT NULL`); - const unlinkedPoints = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_points WHERE district_id IS NULL`); - const adminCount = await this.dataSource.query(`SELECT admin_level, count(*) as cnt FROM admin_boundaries GROUP BY admin_level ORDER BY admin_level`); + const countryPoints = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_points WHERE country = $1`, [detectedCountry]); const diagnostics = { + detected_country: detectedCountry, osm_fetched: elements.length, + country_points: parseInt(countryPoints[0].cnt), total_points_in_db: parseInt(totalPoints[0].cnt), linked_to_district: parseInt(linkedPoints[0].cnt), - unlinked: parseInt(unlinkedPoints[0].cnt), - admin_boundaries: adminCount.map(r => ({ level: r.admin_level, count: parseInt(r.cnt) })), }; this.logger.log(`Step 1 diagnostics: ${JSON.stringify(diagnostics)}`); return diagnostics; @@ -119,28 +128,22 @@ export class AdministrativeLinkingService { } /** - * Step 2: Generate Voronoi Polygons for Neighborhoods + * Step 2: Generate Voronoi Polygons for Neighborhoods (Per Country) */ - async generateVoronoiNeighborhoods() { - this.logger.log('Generating Voronoi polygons for neighborhoods...'); + async generateVoronoiNeighborhoods(country?: string) { + this.logger.log(`Generating Voronoi polygons for ${country || 'all'} neighborhoods...`); // Pre-flight diagnostics - const prePoints = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_points WHERE district_id IS NOT NULL`); - const preDistricts = await this.dataSource.query(`SELECT district_id, count(*) as cnt FROM neighborhood_points WHERE district_id IS NOT NULL GROUP BY district_id`); - this.logger.log(`Pre-flight: ${prePoints[0].cnt} points across ${preDistricts.length} districts`); + const whereClause = country ? `WHERE district_id IS NOT NULL AND country = '${country}'` : `WHERE district_id IS NOT NULL`; + const prePoints = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_points ${whereClause}`); + const preDistricts = await this.dataSource.query(`SELECT district_id, count(*) as cnt FROM neighborhood_points ${whereClause} GROUP BY district_id`); + + // Delete old voronoi polygons for THIS country only + const deleteWhere = country ? `WHERE method = 'voronoi' AND country = '${country}'` : `WHERE method = 'voronoi'`; + await this.dataSource.query(`DELETE FROM neighborhood_polygons ${deleteWhere}`); + this.logger.log(`Deleted old voronoi polygons for ${country || 'all'}.`); - // Log a sample to verify geometry exists - const samplePoints = await this.dataSource.query(`SELECT osm_id, name_ar, district_id, ST_AsText(geometry) as geom_text FROM neighborhood_points WHERE district_id IS NOT NULL LIMIT 3`); - this.logger.log(`Sample points: ${JSON.stringify(samplePoints)}`); - - const sampleDistricts = await this.dataSource.query(`SELECT id, name_ar, admin_level, ST_AsText(ST_Centroid(geom)) as centroid FROM admin_boundaries WHERE admin_level IN (6, 8) LIMIT 3`); - this.logger.log(`Sample districts: ${JSON.stringify(sampleDistricts)}`); - - // Delete old voronoi polygons - await this.dataSource.query(`DELETE FROM neighborhood_polygons WHERE method = 'voronoi'`); - this.logger.log('Deleted old voronoi polygons.'); - - // Generate Voronoi per district - do it one district at a time to avoid silent failures + // Generate Voronoi per district let totalInserted = 0; for (const row of preDistricts) { const districtId = row.district_id; @@ -148,9 +151,8 @@ export class AdministrativeLinkingService { try { if (pointCount >= 2) { - // Normal Voronoi for 2+ points const result = await this.dataSource.query(` - INSERT INTO neighborhood_polygons (osm_id, name_ar, name_en, place_type, parent_id, method, geometry) + INSERT INTO neighborhood_polygons (osm_id, name_ar, name_en, place_type, parent_id, method, country, geometry) WITH voronoi_cells AS ( SELECT (ST_Dump(ST_VoronoiPolygons( ST_Collect(np.geometry::geometry), @@ -164,7 +166,7 @@ export class AdministrativeLinkingService { ), matched AS ( SELECT - np.osm_id, np.name_ar, np.name_en, np.place_type, np.district_id, + np.osm_id, np.name_ar, np.name_en, np.place_type, np.district_id, np.country, ST_Multi(ST_Intersection(vc.cell::geometry, d.geom::geometry)) as geometry FROM voronoi_cells vc CROSS JOIN LATERAL ( @@ -176,53 +178,41 @@ export class AdministrativeLinkingService { ) np JOIN admin_boundaries d ON d.id = $1 ) - SELECT osm_id, name_ar, name_en, place_type, district_id, 'voronoi', geometry + SELECT osm_id, name_ar, name_en, place_type, district_id, 'voronoi', country, geometry FROM matched WHERE ST_IsValid(geometry) AND NOT ST_IsEmpty(geometry) `, [districtId]); totalInserted += (result?.length || result?.[1] || 0); - this.logger.log(`District ${districtId}: ${pointCount} points -> ${result?.length || result?.[1] || '?'} polygons`); } else if (pointCount === 1) { - // Single point: assign entire district boundary - const result = await this.dataSource.query(` - INSERT INTO neighborhood_polygons (osm_id, name_ar, name_en, place_type, parent_id, method, geometry) - SELECT np.osm_id, np.name_ar, np.name_en, np.place_type, np.district_id, 'voronoi', ST_Multi(d.geom::geometry) + await this.dataSource.query(` + INSERT INTO neighborhood_polygons (osm_id, name_ar, name_en, place_type, parent_id, method, country, geometry) + SELECT np.osm_id, np.name_ar, np.name_en, np.place_type, np.district_id, 'voronoi', np.country, ST_Multi(d.geom::geometry) FROM neighborhood_points np JOIN admin_boundaries d ON d.id = np.district_id WHERE np.district_id = $1 `, [districtId]); - - totalInserted += (result?.length || result?.[1] || 0); - this.logger.log(`District ${districtId}: 1 point -> assigned district boundary`); + totalInserted++; } } catch (err) { - this.logger.error(`Voronoi FAILED for district ${districtId} (${pointCount} points): ${err.message}`); + this.logger.error(`Voronoi FAILED for district ${districtId}: ${err.message}`); } } - // Post-flight count - const postCount = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_polygons`); - const finalCount = parseInt(postCount[0].cnt); - this.logger.log(`Step 2 complete: ${finalCount} total polygons in neighborhood_polygons`); - - // Sample polygon names - const samplePolygons = await this.dataSource.query(`SELECT id, osm_id, name_ar, parent_id, method FROM neighborhood_polygons LIMIT 5`); - this.logger.log(`Sample polygons: ${JSON.stringify(samplePolygons)}`); - + const postCount = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_polygons ${country ? `WHERE country = '${country}'` : ''}`); return { - status: finalCount > 0 ? 'success' : 'WARNING_EMPTY', - total_polygons: finalCount, - districts_processed: preDistricts.length, - sample: samplePolygons + status: 'success', + country: country || 'all', + total_polygons: parseInt(postCount[0].cnt), + districts_processed: preDistricts.length }; } /** - * Step 3: Link all Places (Jordan & Syria) to the full administrative hierarchy + * Step 3: Link all Places (Jordan, Syria, Egypt) to the full administrative hierarchy */ - async linkPlaces(country: 'jordan' | 'syria') { - const tableName = country === 'jordan' ? 'places_jordan' : 'places_syria'; + async linkPlaces(country: 'jordan' | 'syria' | 'egypt') { + const tableName = `places_${country}`; // Pre-flight diagnostics const polyCount = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_polygons`); @@ -247,6 +237,7 @@ export class AdministrativeLinkingService { SET neighborhood_id = ( SELECT np.id FROM neighborhood_polygons np + WHERE (np.country = $1 OR np.country IS NULL) ORDER BY p.location::geometry <-> np.geometry::geometry LIMIT 1 ), @@ -269,7 +260,7 @@ export class AdministrativeLinkingService { LIMIT 1 ) WHERE p.location IS NOT NULL; - `); + `, [country]); // Post-flight diagnostics const linkedNeighborhood = await this.dataSource.query(`SELECT count(*) as cnt FROM ${tableName} WHERE neighborhood_id IS NOT NULL`); diff --git a/apps/api/src/geocoding/entities/neighborhood-point.entity.ts b/apps/api/src/geocoding/entities/neighborhood-point.entity.ts index 48c9e27..dc73fab 100644 --- a/apps/api/src/geocoding/entities/neighborhood-point.entity.ts +++ b/apps/api/src/geocoding/entities/neighborhood-point.entity.ts @@ -22,6 +22,10 @@ export class NeighborhoodPoint { @Column({ type: 'int', nullable: true }) @Index() district_id: number; + + @Column({ nullable: true }) + @Index() + country: string; @Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326 }) @Index({ spatial: true }) diff --git a/apps/api/src/geocoding/entities/neighborhood-polygon.entity.ts b/apps/api/src/geocoding/entities/neighborhood-polygon.entity.ts index e2bc901..958329f 100644 --- a/apps/api/src/geocoding/entities/neighborhood-polygon.entity.ts +++ b/apps/api/src/geocoding/entities/neighborhood-polygon.entity.ts @@ -25,6 +25,10 @@ export class NeighborhoodPolygon { @Column({ default: 'voronoi' }) method: string; + + @Column({ nullable: true }) + @Index() + country: string; @Column({ type: 'float', default: 0.7 }) confidence: number; diff --git a/apps/api/src/geocoding/geocoding.controller.ts b/apps/api/src/geocoding/geocoding.controller.ts index afde77f..2e504f8 100644 --- a/apps/api/src/geocoding/geocoding.controller.ts +++ b/apps/api/src/geocoding/geocoding.controller.ts @@ -135,22 +135,24 @@ export class GeocodingController { @UseGuards(ApiKeyGuard) @ApiOperation({ summary: 'Sync neighborhood points from OSM for a bbox' }) @ApiQuery({ name: 'bbox', required: false }) - async syncNeighborhoods(@Query('bbox') bbox?: string) { - return this.adminLinkingService.syncOsmNeighborhoodPoints(bbox); + @ApiQuery({ name: 'country', required: false, enum: ['jordan', 'syria', 'egypt'] }) + async syncNeighborhoods(@Query('bbox') bbox?: string, @Query('country') country?: string) { + return this.adminLinkingService.syncOsmNeighborhoodPoints(bbox, country); } @Post('admin/generate-voronoi') @UseGuards(ApiKeyGuard) @ApiOperation({ summary: 'Generate Voronoi polygons for neighborhoods' }) - async generateVoronoi() { - return this.adminLinkingService.generateVoronoiNeighborhoods(); + @ApiQuery({ name: 'country', required: false, enum: ['jordan', 'syria', 'egypt'] }) + async generateVoronoi(@Query('country') country?: string) { + return this.adminLinkingService.generateVoronoiNeighborhoods(country); } @Post('admin/link-places') @UseGuards(ApiKeyGuard) @ApiOperation({ summary: 'Link places to administrative hierarchy' }) - @ApiQuery({ name: 'country', required: true, enum: ['jordan', 'syria'] }) - async linkPlaces(@Query('country') country: 'jordan' | 'syria') { + @ApiQuery({ name: 'country', required: true, enum: ['jordan', 'syria', 'egypt'] }) + async linkPlaces(@Query('country') country: 'jordan' | 'syria' | 'egypt') { return this.adminLinkingService.linkPlaces(country); } } diff --git a/apps/api/src/geocoding/geocoding.module.ts b/apps/api/src/geocoding/geocoding.module.ts index cc481f1..5b13b80 100644 --- a/apps/api/src/geocoding/geocoding.module.ts +++ b/apps/api/src/geocoding/geocoding.module.ts @@ -36,5 +36,6 @@ import { NeighborhoodPolygon } from './entities/neighborhood-polygon.entity'; JordanResearchService, AdministrativeLinkingService ], + exports: [GeocodingService], }) export class GeocodingModule {} diff --git a/apps/api/src/geocoding/geocoding.service.ts b/apps/api/src/geocoding/geocoding.service.ts index 9ccdc10..f099bd8 100644 --- a/apps/api/src/geocoding/geocoding.service.ts +++ b/apps/api/src/geocoding/geocoding.service.ts @@ -81,11 +81,9 @@ export class GeocodingService { LEFT JOIN neighborhood_polygons n ON p.neighborhood_id = n.id LEFT JOIN admin_boundaries d ON p.sub_district_id = d.id LEFT JOIN admin_boundaries g ON p.governorate_id = g.id - WHERE (p.name_ar % $1 OR p.name % $1 OR p.neighbourhood % $1 OR p.name_ar ILIKE $4 OR p.name ILIKE $4 OR p.neighbourhood ILIKE $4) - ${hasLocation ? `AND (p.location && ST_Expand(ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326), $5::float) OR ST_DistanceSphere(p.location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $6::float)` : ''} - ORDER BY relevance DESC, distance ASC LIMIT 15 + ORDER BY relevance DESC, distance ASC LIMIT 25 `; - const results = await repo.query(userQuery, [cleanQuery, lat || null, lon || null, ILikeQuery, radiusInDegrees, radius]); + const results = await repo.query(userQuery, [cleanQuery, lat || null, lon || null]); allResults.push(...results); } @@ -95,17 +93,33 @@ export class GeocodingService { else if (targetRegion === 'egypt') osmBoundFilter = 'AND ST_Contains(ST_MakeEnvelope(24.5, 22.0, 37.0, 31.8, 4326), geom)'; const osmQuery = ` - SELECT osm_id as id, name, name_ar, name_en, COALESCE(amenity, shop, 'place') as category, - latitude, longitude, addr_street as address, 'osm_global' as source, - CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance, - similarity(COALESCE(name_ar, ''), $1) + similarity(COALESCE(name_en, ''), $1) as relevance - FROM osm_points_with_area - WHERE (name_ar % $1 OR name_en % $1 OR name % $1 OR name_ar ILIKE $4 OR name_en ILIKE $4) - ${osmBoundFilter} - ${hasLocation ? `AND (geom && ST_Expand(ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326), $5::float) OR ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $6::float)` : ''} - ORDER BY relevance DESC, distance ASC LIMIT 20 + SELECT + o.osm_id as id, o.name, o.name_ar, o.name_en, COALESCE(o.amenity, o.shop, 'place') as category, + o.latitude, o.longitude, o.addr_street as address, 'osm_global' as source, + n.name_ar as neighbourhood, + d.name_ar as district, + g.name_ar as governorate, + CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(o.geom, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance, + similarity(COALESCE(o.name_ar, ''), $1) + similarity(COALESCE(o.name_en, ''), $1) as relevance + FROM osm_points_with_area o + LEFT JOIN LATERAL ( + SELECT name_ar FROM neighborhood_polygons np + ORDER BY o.geom <-> np.geometry + LIMIT 1 + ) n ON true + LEFT JOIN LATERAL ( + SELECT name_ar FROM admin_boundaries ab + WHERE ab.admin_level = 8 AND ST_Contains(ab.geom, o.geom) + LIMIT 1 + ) d ON true + LEFT JOIN LATERAL ( + SELECT name_ar FROM admin_boundaries ab + WHERE ab.admin_level = 4 AND ST_Contains(ab.geom, o.geom) + LIMIT 1 + ) g ON true + ORDER BY relevance DESC, distance ASC LIMIT 30 `; - const osmResults = await this.osmPointsRepository.query(osmQuery, [cleanQuery, lat || null, lon || null, ILikeQuery, radiusInDegrees, radius]); + const osmResults = await this.osmPointsRepository.query(osmQuery, [cleanQuery, lat || null, lon || null]); allResults.push(...osmResults); // Note: We removed the raw query to 'overture_building' because raw overture tables @@ -113,8 +127,18 @@ export class GeocodingService { // scanning them with ILIKE without trigram indices causes a 2-second latency spike. // Overture data is already properly ingested via the Scraper into places_jordan. + const seenStreets = new Set(); + const sortedResults = allResults .sort((a, b) => hasLocation ? ((a.distance - b.distance) || (b.relevance - a.relevance)) : ((b.relevance - a.relevance) || (a.distance - b.distance))) + .filter(r => { + if (r.category === 'street') { + const key = `${r.name_ar || r.name}_${r.district}_${r.governorate}`; + if (seenStreets.has(key)) return false; + seenStreets.add(key); + } + return true; + }) .slice(0, 25) .map(r => { // Build full administrative address from admin_boundaries diff --git a/apps/api/src/maps/maps.module.ts b/apps/api/src/maps/maps.module.ts index 1a38d69..ad42a08 100644 --- a/apps/api/src/maps/maps.module.ts +++ b/apps/api/src/maps/maps.module.ts @@ -6,11 +6,13 @@ import { MapsController } from './maps.controller'; import { RoadSegmentStat } from './road-stat.entity'; import { CandidateRoad } from './candidate-road.entity'; import { RedisModule } from '../common/redis.module'; +import { GeocodingModule } from '../geocoding/geocoding.module'; @Module({ imports: [ TypeOrmModule.forFeature([RoadSegmentStat, CandidateRoad]), RedisModule, + GeocodingModule, ], controllers: [MapsController], providers: [MapsService, TrafficGridService], diff --git a/apps/api/src/maps/maps.service.ts b/apps/api/src/maps/maps.service.ts index 1d0c2f2..d285eaf 100644 --- a/apps/api/src/maps/maps.service.ts +++ b/apps/api/src/maps/maps.service.ts @@ -6,6 +6,7 @@ import { RoadSpeedProfile } from './road-speed-profile.entity'; import axios from 'axios'; import { RoadSegmentStat } from './road-stat.entity'; import { TrafficGridService } from './traffic-grid.service'; +import { GeocodingService } from '../geocoding/geocoding.service'; @Injectable() export class MapsService { @@ -15,7 +16,8 @@ export class MapsService { private configService: ConfigService, @InjectRepository(RoadSegmentStat) private roadStatRepo: Repository, - private trafficGrid: TrafficGridService + private trafficGrid: TrafficGridService, + private geocodingService: GeocodingService ) { this.graphHopperUrl = this.configService.get('GRAPH_HOPPER_URL', 'http://routing:8080'); } @@ -29,6 +31,29 @@ export class MapsService { // GraphHopper expects [lng, lat] order const ghPoints = waypoints.map(wp => [wp[1], wp[0]]); + let startName = 'Unknown Location'; + let endName = 'Unknown Location'; + try { + const startWp = waypoints[0]; + const endWp = waypoints[waypoints.length - 1]; + const [startRes, endRes] = await Promise.all([ + this.geocodingService.reverseGeocode(startWp[0], startWp[1]), + this.geocodingService.reverseGeocode(endWp[0], endWp[1]) + ]); + + const formatName = (r: any) => { + const parts = [r.name_ar || r.name, r.neighbourhood, r.district, r.governorate].filter(Boolean); + // Deduplicate items continuously (e.g. if name is similar to neighborhood) + const uniqueParts = [...new Set(parts)]; + return uniqueParts.length > 0 ? uniqueParts.join('، ') : 'Unknown Location'; + }; + + if (startRes && startRes.length > 0) startName = formatName(startRes[0]); + if (endRes && endRes.length > 0) endName = formatName(endRes[0]); + } catch (e) { + console.warn('Geocoding internal error during routing:', e); + } + const payload: any = { points: ghPoints, profile: profile, @@ -79,6 +104,8 @@ export class MapsService { duration: Math.round(baseDuration), trafficAwareDuration: Math.round(trafficAwareDuration), trafficFactor: Math.round(trafficFactor * 100) / 100, + startName, + endName, points: route.points, bbox: route.bbox, alternatives: alternatives // NEW: array of other routes