2026-04-14-2 full
This commit is contained in:
@@ -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 {}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user