2026-04-13-4
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AdminBoundary } from './entities/admin-boundary.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AdminBoundariesService {
|
||||
private readonly logger = new Logger(AdminBoundariesService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AdminBoundary)
|
||||
private adminBoundaryRepo: Repository<AdminBoundary>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Import GeoJSON features into the admin_boundaries table.
|
||||
* Expects feature.properties to contain 'admin_level', 'name:ar'/'name', 'name:en'
|
||||
*/
|
||||
async importGeoJSON(
|
||||
targetCountryCode: string,
|
||||
geoJson: any
|
||||
): Promise<{ success: boolean; imported: number; errors: number; skipped: number }> {
|
||||
this.logger.log(`Importing admin boundaries for ${targetCountryCode}...`);
|
||||
|
||||
let imported = 0;
|
||||
let errors = 0;
|
||||
let skipped = 0;
|
||||
|
||||
if (!geoJson || !geoJson.features || !Array.isArray(geoJson.features)) {
|
||||
this.logger.error('Invalid GeoJSON format');
|
||||
return { success: false, imported: 0, errors: 1, skipped: 0 };
|
||||
}
|
||||
|
||||
for (const feature of geoJson.features) {
|
||||
try {
|
||||
const props = feature.properties || {};
|
||||
const countryCode = props.country || props.iso_3166_1_alpha2;
|
||||
|
||||
// 1. Filter by country code to avoid importing overlapping data (e.g. Hebrew names in Jordan)
|
||||
if (targetCountryCode && countryCode && countryCode.toUpperCase() !== targetCountryCode.toUpperCase()) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Map Overture Subtype to our Admin Level
|
||||
// Overture: country, region, county, localadmin, locality, neighborhood
|
||||
const subtype = (props.subtype || '').toLowerCase();
|
||||
let adminLevel: number;
|
||||
|
||||
switch (subtype) {
|
||||
case 'country': adminLevel = 2; break;
|
||||
case 'region': adminLevel = 4; break; // Governorate (Muhafazah)
|
||||
case 'county': adminLevel = 6; break; // District (Liwa)
|
||||
case 'localadmin': adminLevel = 8; break; // Sub-district (Qada)
|
||||
case 'locality': adminLevel = 8; break; // City/Town
|
||||
case 'neighborhood': adminLevel = 10; break;
|
||||
case 'macrohood': adminLevel = 9; break; // Intermediate
|
||||
case 'microhood': adminLevel = 11; break; // Sub-neighborhood
|
||||
default:
|
||||
adminLevel = parseInt(props.admin_level || props.adminLevel, 10);
|
||||
}
|
||||
|
||||
if (isNaN(adminLevel)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Name Handling (Prioritize Arabic)
|
||||
const nameAr = props['names']?.['primary'] || props['names']?.['common'] || props['name_ar'] || props['name'];
|
||||
const nameEn = props['names']?.['en'] || props['name_en'];
|
||||
|
||||
// Simple check to skip obviously non-Arabic primary names if the target is an Arabic country
|
||||
const isArabic = (text: string) => /[\u0600-\u06FF]/.test(text);
|
||||
if (['JO', 'SY', 'EG'].includes(targetCountryCode.toUpperCase()) && nameAr && !isArabic(nameAr)) {
|
||||
// If primary name isn't Arabic, try to find an Arabic variant in names map
|
||||
const alternativeAr = Object.values(props['names'] || {}).find(v => typeof v === 'string' && isArabic(v));
|
||||
if (!alternativeAr) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let geom = feature.geometry;
|
||||
if (geom.type === 'Polygon') {
|
||||
geom = { type: 'MultiPolygon', coordinates: [geom.coordinates] };
|
||||
} else if (geom.type !== 'MultiPolygon') {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const boundary = this.adminBoundaryRepo.create({
|
||||
country_code: targetCountryCode.toUpperCase(),
|
||||
admin_level: adminLevel,
|
||||
name_ar: nameAr,
|
||||
name_en: nameEn,
|
||||
geom: geom,
|
||||
});
|
||||
|
||||
await this.adminBoundaryRepo.save(boundary);
|
||||
imported++;
|
||||
} catch (err) {
|
||||
this.logger.error(`Error importing feature: ${err.message}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Import finished. Imported: ${imported}, Errors: ${errors}, Skipped: ${skipped}`);
|
||||
return { success: true, imported, errors, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import GeoJSON from a local file on the server.
|
||||
*/
|
||||
async importFromFile(countryCode: string, filePath: string): Promise<{ success: boolean; imported: number; errors: number; skipped: number }> {
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(filePath)) {
|
||||
this.logger.error(`File not found: ${filePath}`);
|
||||
return { success: false, imported: 0, errors: 1, skipped: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(filePath, 'utf8');
|
||||
const geoJson = JSON.parse(data);
|
||||
return this.importGeoJSON(countryCode, geoJson);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to read or parse GeoJSON file: ${err.message}`);
|
||||
return { success: false, imported: 0, errors: 1, skipped: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
|
||||
|
||||
@Entity('admin_boundaries')
|
||||
export class AdminBoundary {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ length: 3 })
|
||||
@Index()
|
||||
country_code: string;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
@Index()
|
||||
admin_level: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Index()
|
||||
name_ar: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
name_en: string;
|
||||
|
||||
@Column({ type: 'geometry', spatialFeatureType: 'MultiPolygon', srid: 4326, nullable: true })
|
||||
@Index({ spatial: true })
|
||||
geom: any;
|
||||
}
|
||||
@@ -45,4 +45,20 @@ export abstract class BasePlace {
|
||||
@Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326, nullable: true })
|
||||
@Index({ spatial: true })
|
||||
location: any;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
@Index()
|
||||
admin_level4_id: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
@Index()
|
||||
admin_level6_id: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
@Index()
|
||||
admin_level8_id: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
@Index()
|
||||
admin_level10_id: number;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@ export class GeocodingInitService implements OnModuleInit {
|
||||
BEGIN
|
||||
IF NEW.latitude IS NOT NULL AND NEW.longitude IS NOT NULL THEN
|
||||
NEW.location := ST_SetSRID(ST_MakePoint(CAST(NEW.longitude AS FLOAT), CAST(NEW.latitude AS FLOAT)), 4326);
|
||||
|
||||
NEW.admin_level4_id := (SELECT id FROM admin_boundaries WHERE admin_level = 4 AND ST_Contains(geom, NEW.location) LIMIT 1);
|
||||
NEW.admin_level6_id := (SELECT id FROM admin_boundaries WHERE admin_level = 6 AND ST_Contains(geom, NEW.location) LIMIT 1);
|
||||
NEW.admin_level8_id := (SELECT id FROM admin_boundaries WHERE admin_level = 8 AND ST_Contains(geom, NEW.location) LIMIT 1);
|
||||
NEW.admin_level10_id := (SELECT id FROM admin_boundaries WHERE admin_level = 10 AND ST_Contains(geom, NEW.location) LIMIT 1);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Controller, Get, Post, Delete, Body, Query, UseGuards, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import { GeocodingService } from './geocoding.service';
|
||||
import { AdminBoundariesService } from './admin-boundaries.service';
|
||||
import { ApiKeyGuard } from '../common/guards/api-key.guard';
|
||||
|
||||
@ApiTags('geocoding')
|
||||
@Controller('geocoding')
|
||||
export class GeocodingController {
|
||||
constructor(private readonly geocodingService: GeocodingService) {}
|
||||
constructor(
|
||||
private readonly geocodingService: GeocodingService,
|
||||
private readonly adminBoundariesService: AdminBoundariesService,
|
||||
) {}
|
||||
|
||||
@Get('search')
|
||||
@ApiOperation({ summary: 'Search for locations (Forward Geocoding)' })
|
||||
@@ -104,4 +108,16 @@ export class GeocodingController {
|
||||
async getGeoJSON() {
|
||||
return this.geocodingService.getAllPlacesGeoJSON();
|
||||
}
|
||||
|
||||
@Post('import-boundaries')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@ApiOperation({ summary: 'Import administrative boundaries from a local GeoJSON file on the server' })
|
||||
@ApiQuery({ name: 'country', required: true })
|
||||
@ApiQuery({ name: 'filePath', required: true })
|
||||
async importBoundaries(
|
||||
@Query('country') country: string,
|
||||
@Query('filePath') filePath: string,
|
||||
) {
|
||||
return this.adminBoundariesService.importFromFile(country, filePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import { PlaceJordan } from './entities/place-jordan.entity';
|
||||
import { PlaceEgypt } from './entities/place-egypt.entity';
|
||||
import { OsmArea } from './entities/osm-area.entity';
|
||||
import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
|
||||
|
||||
import { AdminBoundary } from './entities/admin-boundary.entity';
|
||||
import { AdminBoundariesService } from './admin-boundaries.service';
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
@@ -16,10 +17,11 @@ import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
|
||||
PlaceJordan,
|
||||
PlaceEgypt,
|
||||
OsmArea,
|
||||
OsmPointWithArea
|
||||
OsmPointWithArea,
|
||||
AdminBoundary
|
||||
]),
|
||||
],
|
||||
controllers: [GeocodingController],
|
||||
providers: [GeocodingService, GeocodingInitService],
|
||||
providers: [GeocodingService, GeocodingInitService, AdminBoundariesService],
|
||||
})
|
||||
export class GeocodingModule {}
|
||||
|
||||
@@ -67,12 +67,21 @@ export class GeocodingService {
|
||||
for (const tableName of userTables) {
|
||||
let repo: Repository<any> = tableName === 'places_egypt' ? this.placesEgyptRepository : (tableName === 'places_jordan' ? this.placesJordanRepository : this.placesSyriaRepository);
|
||||
const userQuery = `
|
||||
SELECT id, name, name_ar, name_en, category, neighbourhood, latitude, longitude, address, '${tableName.replace('places_', '')}' as region, 'user_submitted' as source,
|
||||
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
|
||||
similarity(COALESCE(name_ar, ''), $1) + similarity(COALESCE(name, ''), $1) + similarity(COALESCE(neighbourhood, ''), $1) as relevance
|
||||
FROM ${tableName}
|
||||
WHERE (name_ar % $1 OR name % $1 OR neighbourhood % $1 OR name_ar ILIKE $4 OR name ILIKE $4 OR neighbourhood ILIKE $4)
|
||||
${hasLocation ? `AND (location && ST_Expand(ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326), $5::float) OR ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $6::float)` : ''}
|
||||
SELECT
|
||||
p.id, p.name, p.name_ar, p.name_en, p.category,
|
||||
p.neighbourhood as original_neighbourhood,
|
||||
n.name_ar as neighbourhood,
|
||||
d.name_ar as district,
|
||||
g.name_ar as governorate,
|
||||
p.latitude, p.longitude, p.address, '${tableName.replace('places_', '')}' as region, p.source,
|
||||
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(p.location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
|
||||
similarity(COALESCE(p.name_ar, ''), $1) + similarity(COALESCE(p.name, ''), $1) + similarity(COALESCE(p.neighbourhood, ''), $1) as relevance
|
||||
FROM ${tableName} p
|
||||
LEFT JOIN admin_boundaries n ON p.admin_level10_id = n.id
|
||||
LEFT JOIN admin_boundaries d ON p.admin_level8_id = d.id
|
||||
LEFT JOIN admin_boundaries g ON p.admin_level4_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
|
||||
`;
|
||||
const results = await repo.query(userQuery, [cleanQuery, lat || null, lon || null, ILikeQuery, radiusInDegrees, radius]);
|
||||
@@ -98,9 +107,48 @@ export class GeocodingService {
|
||||
const osmResults = await this.osmPointsRepository.query(osmQuery, [cleanQuery, lat || null, lon || null, ILikeQuery, radiusInDegrees, radius]);
|
||||
allResults.push(...osmResults);
|
||||
|
||||
// --- Consolidation: Overture Maps Integration (Now in primary DB) ---
|
||||
const overtureQuery = `
|
||||
(SELECT id::text,
|
||||
COALESCE(names->>'primary', names->>'common', 'Building') as name,
|
||||
COALESCE(names->>'primary', names->>'common', '') as name_ar,
|
||||
NULL as name_en,
|
||||
'building' as category,
|
||||
ST_Y(ST_Centroid(location)) as latitude,
|
||||
ST_X(ST_Centroid(location)) as longitude,
|
||||
'' as address,
|
||||
'overture_buildings' as source,
|
||||
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
|
||||
0.6 as relevance
|
||||
FROM overture_building
|
||||
WHERE (names->>'primary' ILIKE $4 OR names->>'common' ILIKE $4)
|
||||
LIMIT 15)
|
||||
UNION ALL
|
||||
(SELECT id::text,
|
||||
COALESCE(names->>'primary', names->>'common', 'Street') as name,
|
||||
COALESCE(names->>'primary', names->>'common', '') as name_ar,
|
||||
NULL as name_en,
|
||||
'transportation' as category,
|
||||
ST_Y(ST_Centroid(location)) as latitude,
|
||||
ST_X(ST_Centroid(location)) as longitude,
|
||||
'' as address,
|
||||
'overture_streets' as source,
|
||||
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
|
||||
0.6 as relevance
|
||||
FROM overture_segment
|
||||
WHERE (names->>'primary' ILIKE $4 OR names->>'common' ILIKE $4)
|
||||
LIMIT 15)
|
||||
`;
|
||||
try {
|
||||
const overtureResults = await this.osmPointsRepository.query(overtureQuery, [cleanQuery, lat || null, lon || null, ILikeQuery]);
|
||||
allResults.push(...overtureResults);
|
||||
} catch (err) {
|
||||
this.logger.warn('Overture tables search failed, logging error.', err);
|
||||
}
|
||||
|
||||
const sortedResults = allResults
|
||||
.sort((a, b) => hasLocation ? ((a.distance - b.distance) || (b.relevance - a.relevance)) : ((b.relevance - a.relevance) || (a.distance - b.distance)))
|
||||
.slice(0, 20)
|
||||
.slice(0, 25)
|
||||
.map(r => ({
|
||||
...r,
|
||||
latitude: parseFloat(r.latitude),
|
||||
@@ -111,7 +159,7 @@ export class GeocodingService {
|
||||
|
||||
return { results: sortedResults };
|
||||
} catch (e) {
|
||||
this.logger.error('Optimized Spatial Search failed:', e);
|
||||
this.logger.error('Search failed:', e);
|
||||
return { results: [] };
|
||||
}
|
||||
}
|
||||
@@ -121,10 +169,19 @@ export class GeocodingService {
|
||||
const repo = this.getRepositoryForCoords(lat, lng);
|
||||
const tableName = this.getTableNameForRepo(repo);
|
||||
const query = `
|
||||
SELECT id, name, name_ar, category, latitude, longitude, address, 'user_place' as source,
|
||||
ST_DistanceSphere(location::geometry, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
|
||||
FROM ${tableName} WHERE location IS NOT NULL
|
||||
ORDER BY location::geometry <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 3
|
||||
SELECT
|
||||
p.id, p.name, p.name_ar, p.category,
|
||||
n.name_ar as neighbourhood,
|
||||
d.name_ar as district,
|
||||
g.name_ar as governorate,
|
||||
p.latitude, p.longitude, p.address, 'user_place' as source,
|
||||
ST_DistanceSphere(p.location::geometry, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
|
||||
FROM ${tableName} p
|
||||
LEFT JOIN admin_boundaries n ON p.admin_level10_id = n.id
|
||||
LEFT JOIN admin_boundaries d ON p.admin_level8_id = d.id
|
||||
LEFT JOIN admin_boundaries g ON p.admin_level4_id = g.id
|
||||
WHERE p.location IS NOT NULL
|
||||
ORDER BY p.location::geometry <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 3
|
||||
`;
|
||||
return await repo.query(query, [lng, lat]);
|
||||
} catch (error) {
|
||||
@@ -173,11 +230,23 @@ export class GeocodingService {
|
||||
|
||||
async getAllPlacesGeoJSON() {
|
||||
try {
|
||||
const q = `SELECT id, name_ar as name, category, latitude, longitude, address, 'syria' as region FROM places_syria UNION ALL SELECT id, name_ar as name, category, latitude, longitude, address, 'jordan' as region FROM places_jordan UNION ALL SELECT id, name_ar as name, category, latitude, longitude, address, 'egypt' as region FROM places_egypt`;
|
||||
const q = `
|
||||
SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_syria
|
||||
UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_jordan
|
||||
UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_egypt
|
||||
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Building') as name, 'building' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_building WHERE names->>'primary' IS NOT NULL LIMIT 500
|
||||
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Street') as name, 'street' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_segment WHERE names->>'primary' IS NOT NULL LIMIT 500
|
||||
`;
|
||||
const res = await this.placesSyriaRepository.query(q);
|
||||
const features = res.map(p => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [parseFloat(p.longitude), parseFloat(p.latitude)] }, properties: { id: p.id, name: p.name, category: p.category, address: p.address, region: p.region }}));
|
||||
const features = res.map(p => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [parseFloat(p.longitude), parseFloat(p.latitude)] },
|
||||
properties: { id: p.id, name: p.name, category: p.category, address: p.address, region: p.region }
|
||||
}));
|
||||
return { type: 'FeatureCollection', features };
|
||||
} catch (e) { return { type: 'FeatureCollection', features: [] }; }
|
||||
} catch (e) {
|
||||
return { type: 'FeatureCollection', features: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async deletePlacesByName(name: string, country: string) {
|
||||
|
||||
Reference in New Issue
Block a user