2026-04-14-2 full

This commit is contained in:
Hamza-Ayed
2026-04-14 13:28:19 +03:00
parent 581eda1ea8
commit 830b498485
8 changed files with 138 additions and 83 deletions
@@ -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') { async syncOsmNeighborhoodPoints(bbox: string = '31.86,35.94,32.22,36.25', country?: string) {
this.logger.log(`Syncing neighborhood points for bbox: ${bbox}`); 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;`; const query = `[out:json][timeout:900];node["place"~"neighbourhood|suburb|town"](${bbox});out;`;
try { try {
@@ -60,23 +60,33 @@ export class AdministrativeLinkingService {
const elements = data.elements; const elements = data.elements;
this.logger.log(`Found ${elements.length} points in OSM.`); 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) { for (const e of elements) {
const nameAr = e.tags['name:ar'] || e.tags['name']; const nameAr = e.tags['name:ar'] || e.tags['name'];
const nameEn = e.tags['name:en'] || e.tags['name']; const nameEn = e.tags['name:en'] || e.tags['name'];
const geometry = { type: 'Point', coordinates: [e.lon, e.lat] }; const geometry = { type: 'Point', coordinates: [e.lon, e.lat] };
await this.dataSource.query(` await this.dataSource.query(`
INSERT INTO neighborhood_points (osm_id, name_ar, name_en, place_type, geometry) 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)) VALUES ($1, $2, $3, $4, ST_SetSRID(ST_GeomFromGeoJSON($5), 4326), $6)
ON CONFLICT (osm_id) DO UPDATE SET ON CONFLICT (osm_id) DO UPDATE SET
name_ar = EXCLUDED.name_ar, name_ar = EXCLUDED.name_ar,
name_en = EXCLUDED.name_en, name_en = EXCLUDED.name_en,
place_type = EXCLUDED.place_type, place_type = EXCLUDED.place_type,
geometry = EXCLUDED.geometry; geometry = EXCLUDED.geometry,
`, [e.id, nameAr, nameEn, e.tags['place'], JSON.stringify(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(` await this.dataSource.query(`
UPDATE neighborhood_points np UPDATE neighborhood_points np
SET district_id = COALESCE( SET district_id = COALESCE(
@@ -94,21 +104,20 @@ export class AdministrativeLinkingService {
LIMIT 1 LIMIT 1
) )
) )
WHERE district_id IS NULL; WHERE district_id IS NULL AND (country = $1 OR $1 IS NULL);
`); `, [detectedCountry]);
// Diagnostics // Diagnostics
const totalPoints = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_points`); 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 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 countryPoints = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_points WHERE country = $1`, [detectedCountry]);
const adminCount = await this.dataSource.query(`SELECT admin_level, count(*) as cnt FROM admin_boundaries GROUP BY admin_level ORDER BY admin_level`);
const diagnostics = { const diagnostics = {
detected_country: detectedCountry,
osm_fetched: elements.length, osm_fetched: elements.length,
country_points: parseInt(countryPoints[0].cnt),
total_points_in_db: parseInt(totalPoints[0].cnt), total_points_in_db: parseInt(totalPoints[0].cnt),
linked_to_district: parseInt(linkedPoints[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)}`); this.logger.log(`Step 1 diagnostics: ${JSON.stringify(diagnostics)}`);
return 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() { async generateVoronoiNeighborhoods(country?: string) {
this.logger.log('Generating Voronoi polygons for neighborhoods...'); this.logger.log(`Generating Voronoi polygons for ${country || 'all'} neighborhoods...`);
// Pre-flight diagnostics // Pre-flight diagnostics
const prePoints = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_points WHERE district_id IS NOT NULL`); const whereClause = country ? `WHERE district_id IS NOT NULL AND country = '${country}'` : `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`); const prePoints = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_points ${whereClause}`);
this.logger.log(`Pre-flight: ${prePoints[0].cnt} points across ${preDistricts.length} districts`); const preDistricts = await this.dataSource.query(`SELECT district_id, count(*) as cnt FROM neighborhood_points ${whereClause} GROUP BY district_id`);
// Log a sample to verify geometry exists // Delete old voronoi polygons for THIS country only
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`); const deleteWhere = country ? `WHERE method = 'voronoi' AND country = '${country}'` : `WHERE method = 'voronoi'`;
this.logger.log(`Sample points: ${JSON.stringify(samplePoints)}`); await this.dataSource.query(`DELETE FROM neighborhood_polygons ${deleteWhere}`);
this.logger.log(`Deleted old voronoi polygons for ${country || 'all'}.`);
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`); // Generate Voronoi per district
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
let totalInserted = 0; let totalInserted = 0;
for (const row of preDistricts) { for (const row of preDistricts) {
const districtId = row.district_id; const districtId = row.district_id;
@@ -148,9 +151,8 @@ export class AdministrativeLinkingService {
try { try {
if (pointCount >= 2) { if (pointCount >= 2) {
// Normal Voronoi for 2+ points
const result = await this.dataSource.query(` 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 ( WITH voronoi_cells AS (
SELECT (ST_Dump(ST_VoronoiPolygons( SELECT (ST_Dump(ST_VoronoiPolygons(
ST_Collect(np.geometry::geometry), ST_Collect(np.geometry::geometry),
@@ -164,7 +166,7 @@ export class AdministrativeLinkingService {
), ),
matched AS ( matched AS (
SELECT 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 ST_Multi(ST_Intersection(vc.cell::geometry, d.geom::geometry)) as geometry
FROM voronoi_cells vc FROM voronoi_cells vc
CROSS JOIN LATERAL ( CROSS JOIN LATERAL (
@@ -176,53 +178,41 @@ export class AdministrativeLinkingService {
) np ) np
JOIN admin_boundaries d ON d.id = $1 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 FROM matched
WHERE ST_IsValid(geometry) AND NOT ST_IsEmpty(geometry) WHERE ST_IsValid(geometry) AND NOT ST_IsEmpty(geometry)
`, [districtId]); `, [districtId]);
totalInserted += (result?.length || result?.[1] || 0); totalInserted += (result?.length || result?.[1] || 0);
this.logger.log(`District ${districtId}: ${pointCount} points -> ${result?.length || result?.[1] || '?'} polygons`);
} else if (pointCount === 1) { } else if (pointCount === 1) {
// Single point: assign entire district boundary await this.dataSource.query(`
const result = await this.dataSource.query(` INSERT INTO neighborhood_polygons (osm_id, name_ar, name_en, place_type, parent_id, method, country, geometry)
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', np.country, ST_Multi(d.geom::geometry)
SELECT np.osm_id, np.name_ar, np.name_en, np.place_type, np.district_id, 'voronoi', ST_Multi(d.geom::geometry)
FROM neighborhood_points np FROM neighborhood_points np
JOIN admin_boundaries d ON d.id = np.district_id JOIN admin_boundaries d ON d.id = np.district_id
WHERE np.district_id = $1 WHERE np.district_id = $1
`, [districtId]); `, [districtId]);
totalInserted++;
totalInserted += (result?.length || result?.[1] || 0);
this.logger.log(`District ${districtId}: 1 point -> assigned district boundary`);
} }
} catch (err) { } 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 ${country ? `WHERE country = '${country}'` : ''}`);
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)}`);
return { return {
status: finalCount > 0 ? 'success' : 'WARNING_EMPTY', status: 'success',
total_polygons: finalCount, country: country || 'all',
districts_processed: preDistricts.length, total_polygons: parseInt(postCount[0].cnt),
sample: samplePolygons 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') { async linkPlaces(country: 'jordan' | 'syria' | 'egypt') {
const tableName = country === 'jordan' ? 'places_jordan' : 'places_syria'; const tableName = `places_${country}`;
// Pre-flight diagnostics // Pre-flight diagnostics
const polyCount = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_polygons`); const polyCount = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_polygons`);
@@ -247,6 +237,7 @@ export class AdministrativeLinkingService {
SET SET
neighborhood_id = ( neighborhood_id = (
SELECT np.id FROM neighborhood_polygons np SELECT np.id FROM neighborhood_polygons np
WHERE (np.country = $1 OR np.country IS NULL)
ORDER BY p.location::geometry <-> np.geometry::geometry ORDER BY p.location::geometry <-> np.geometry::geometry
LIMIT 1 LIMIT 1
), ),
@@ -269,7 +260,7 @@ export class AdministrativeLinkingService {
LIMIT 1 LIMIT 1
) )
WHERE p.location IS NOT NULL; WHERE p.location IS NOT NULL;
`); `, [country]);
// Post-flight diagnostics // Post-flight diagnostics
const linkedNeighborhood = await this.dataSource.query(`SELECT count(*) as cnt FROM ${tableName} WHERE neighborhood_id IS NOT NULL`); const linkedNeighborhood = await this.dataSource.query(`SELECT count(*) as cnt FROM ${tableName} WHERE neighborhood_id IS NOT NULL`);
@@ -23,6 +23,10 @@ export class NeighborhoodPoint {
@Index() @Index()
district_id: number; district_id: number;
@Column({ nullable: true })
@Index()
country: string;
@Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326 }) @Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326 })
@Index({ spatial: true }) @Index({ spatial: true })
geometry: any; geometry: any;
@@ -26,6 +26,10 @@ export class NeighborhoodPolygon {
@Column({ default: 'voronoi' }) @Column({ default: 'voronoi' })
method: string; method: string;
@Column({ nullable: true })
@Index()
country: string;
@Column({ type: 'float', default: 0.7 }) @Column({ type: 'float', default: 0.7 })
confidence: number; confidence: number;
@@ -135,22 +135,24 @@ export class GeocodingController {
@UseGuards(ApiKeyGuard) @UseGuards(ApiKeyGuard)
@ApiOperation({ summary: 'Sync neighborhood points from OSM for a bbox' }) @ApiOperation({ summary: 'Sync neighborhood points from OSM for a bbox' })
@ApiQuery({ name: 'bbox', required: false }) @ApiQuery({ name: 'bbox', required: false })
async syncNeighborhoods(@Query('bbox') bbox?: string) { @ApiQuery({ name: 'country', required: false, enum: ['jordan', 'syria', 'egypt'] })
return this.adminLinkingService.syncOsmNeighborhoodPoints(bbox); async syncNeighborhoods(@Query('bbox') bbox?: string, @Query('country') country?: string) {
return this.adminLinkingService.syncOsmNeighborhoodPoints(bbox, country);
} }
@Post('admin/generate-voronoi') @Post('admin/generate-voronoi')
@UseGuards(ApiKeyGuard) @UseGuards(ApiKeyGuard)
@ApiOperation({ summary: 'Generate Voronoi polygons for neighborhoods' }) @ApiOperation({ summary: 'Generate Voronoi polygons for neighborhoods' })
async generateVoronoi() { @ApiQuery({ name: 'country', required: false, enum: ['jordan', 'syria', 'egypt'] })
return this.adminLinkingService.generateVoronoiNeighborhoods(); async generateVoronoi(@Query('country') country?: string) {
return this.adminLinkingService.generateVoronoiNeighborhoods(country);
} }
@Post('admin/link-places') @Post('admin/link-places')
@UseGuards(ApiKeyGuard) @UseGuards(ApiKeyGuard)
@ApiOperation({ summary: 'Link places to administrative hierarchy' }) @ApiOperation({ summary: 'Link places to administrative hierarchy' })
@ApiQuery({ name: 'country', required: true, enum: ['jordan', 'syria'] }) @ApiQuery({ name: 'country', required: true, enum: ['jordan', 'syria', 'egypt'] })
async linkPlaces(@Query('country') country: 'jordan' | 'syria') { async linkPlaces(@Query('country') country: 'jordan' | 'syria' | 'egypt') {
return this.adminLinkingService.linkPlaces(country); return this.adminLinkingService.linkPlaces(country);
} }
} }
@@ -36,5 +36,6 @@ import { NeighborhoodPolygon } from './entities/neighborhood-polygon.entity';
JordanResearchService, JordanResearchService,
AdministrativeLinkingService AdministrativeLinkingService
], ],
exports: [GeocodingService],
}) })
export class GeocodingModule {} export class GeocodingModule {}
+38 -14
View File
@@ -81,11 +81,9 @@ export class GeocodingService {
LEFT JOIN neighborhood_polygons n ON p.neighborhood_id = n.id 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 d ON p.sub_district_id = d.id
LEFT JOIN admin_boundaries g ON p.governorate_id = g.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) ORDER BY relevance DESC, distance ASC LIMIT 25
${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
`; `;
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); 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)'; else if (targetRegion === 'egypt') osmBoundFilter = 'AND ST_Contains(ST_MakeEnvelope(24.5, 22.0, 37.0, 31.8, 4326), geom)';
const osmQuery = ` const osmQuery = `
SELECT osm_id as id, name, name_ar, name_en, COALESCE(amenity, shop, 'place') as category, SELECT
latitude, longitude, addr_street as address, 'osm_global' as source, o.osm_id as id, o.name, o.name_ar, o.name_en, COALESCE(o.amenity, o.shop, 'place') as category,
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, o.latitude, o.longitude, o.addr_street as address, 'osm_global' as source,
similarity(COALESCE(name_ar, ''), $1) + similarity(COALESCE(name_en, ''), $1) as relevance n.name_ar as neighbourhood,
FROM osm_points_with_area d.name_ar as district,
WHERE (name_ar % $1 OR name_en % $1 OR name % $1 OR name_ar ILIKE $4 OR name_en ILIKE $4) g.name_ar as governorate,
${osmBoundFilter} 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,
${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)` : ''} similarity(COALESCE(o.name_ar, ''), $1) + similarity(COALESCE(o.name_en, ''), $1) as relevance
ORDER BY relevance DESC, distance ASC LIMIT 20 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); allResults.push(...osmResults);
// Note: We removed the raw query to 'overture_building' because raw overture tables // 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. // 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. // Overture data is already properly ingested via the Scraper into places_jordan.
const seenStreets = new Set<string>();
const sortedResults = allResults const sortedResults = allResults
.sort((a, b) => hasLocation ? ((a.distance - b.distance) || (b.relevance - a.relevance)) : ((b.relevance - a.relevance) || (a.distance - b.distance))) .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) .slice(0, 25)
.map(r => { .map(r => {
// Build full administrative address from admin_boundaries // Build full administrative address from admin_boundaries
+2
View File
@@ -6,11 +6,13 @@ import { MapsController } from './maps.controller';
import { RoadSegmentStat } from './road-stat.entity'; import { RoadSegmentStat } from './road-stat.entity';
import { CandidateRoad } from './candidate-road.entity'; import { CandidateRoad } from './candidate-road.entity';
import { RedisModule } from '../common/redis.module'; import { RedisModule } from '../common/redis.module';
import { GeocodingModule } from '../geocoding/geocoding.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([RoadSegmentStat, CandidateRoad]), TypeOrmModule.forFeature([RoadSegmentStat, CandidateRoad]),
RedisModule, RedisModule,
GeocodingModule,
], ],
controllers: [MapsController], controllers: [MapsController],
providers: [MapsService, TrafficGridService], providers: [MapsService, TrafficGridService],
+28 -1
View File
@@ -6,6 +6,7 @@ import { RoadSpeedProfile } from './road-speed-profile.entity';
import axios from 'axios'; import axios from 'axios';
import { RoadSegmentStat } from './road-stat.entity'; import { RoadSegmentStat } from './road-stat.entity';
import { TrafficGridService } from './traffic-grid.service'; import { TrafficGridService } from './traffic-grid.service';
import { GeocodingService } from '../geocoding/geocoding.service';
@Injectable() @Injectable()
export class MapsService { export class MapsService {
@@ -15,7 +16,8 @@ export class MapsService {
private configService: ConfigService, private configService: ConfigService,
@InjectRepository(RoadSegmentStat) @InjectRepository(RoadSegmentStat)
private roadStatRepo: Repository<RoadSegmentStat>, private roadStatRepo: Repository<RoadSegmentStat>,
private trafficGrid: TrafficGridService private trafficGrid: TrafficGridService,
private geocodingService: GeocodingService
) { ) {
this.graphHopperUrl = this.configService.get('GRAPH_HOPPER_URL', 'http://routing:8080'); this.graphHopperUrl = this.configService.get('GRAPH_HOPPER_URL', 'http://routing:8080');
} }
@@ -29,6 +31,29 @@ export class MapsService {
// GraphHopper expects [lng, lat] order // GraphHopper expects [lng, lat] order
const ghPoints = waypoints.map(wp => [wp[1], wp[0]]); 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 = { const payload: any = {
points: ghPoints, points: ghPoints,
profile: profile, profile: profile,
@@ -79,6 +104,8 @@ export class MapsService {
duration: Math.round(baseDuration), duration: Math.round(baseDuration),
trafficAwareDuration: Math.round(trafficAwareDuration), trafficAwareDuration: Math.round(trafficAwareDuration),
trafficFactor: Math.round(trafficFactor * 100) / 100, trafficFactor: Math.round(trafficFactor * 100) / 100,
startName,
endName,
points: route.points, points: route.points,
bbox: route.bbox, bbox: route.bbox,
alternatives: alternatives // NEW: array of other routes alternatives: alternatives // NEW: array of other routes