Final Refinement: Native Labels, Interactive Management UI, and Visual Polishing

This commit is contained in:
Hamza-Ayed
2026-03-29 02:55:28 +03:00
parent 014848247d
commit ca043b67a8
10 changed files with 5141 additions and 1367 deletions
@@ -38,17 +38,30 @@ export class GeocodingInitService implements OnModuleInit {
FOR EACH ROW EXECUTE FUNCTION sync_place_location();
`);
// 1. GIST Geometry Indexes (for fast proximity search)
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_places_syria_location ON places_syria USING gist (location);');
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_osm_areas_geom ON osm_areas USING gist (geom);');
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_osm_points_geom ON osm_points_with_area USING gist (geom);');
// 3. Triggers for Jordan and Egypt
await this.repo.query(`
DROP TRIGGER IF EXISTS trg_sync_place_location_jordan ON places_jordan;
CREATE TRIGGER trg_sync_place_location_jordan
BEFORE INSERT OR UPDATE ON places_jordan
FOR EACH ROW EXECUTE FUNCTION sync_place_location();
`);
// 2. GIST Trigram Indexes (for fast fuzzy name search and low server load)
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_places_syria_names_trgm ON places_syria USING gist (name_ar gist_trgm_ops, name_en gist_trgm_ops);');
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_osm_areas_names_trgm ON osm_areas USING gist (name_ar gist_trgm_ops);');
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_osm_points_names_trgm ON osm_points_with_area USING gist (name_ar gist_trgm_ops, name_en gist_trgm_ops);');
await this.repo.query(`
DROP TRIGGER IF EXISTS trg_sync_place_location_egypt ON places_egypt;
CREATE TRIGGER trg_sync_place_location_egypt
BEFORE INSERT OR UPDATE ON places_egypt
FOR EACH ROW EXECUTE FUNCTION sync_place_location();
`);
this.logger.log('Geocoding database triggers and optimized indexes initialized.');
// 4. GIST Geometry Indexes for Jordan and Egypt
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_places_jordan_location ON places_jordan USING gist (location);');
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_places_egypt_location ON places_egypt USING gist (location);');
// 5. GIST Trigram Indexes for Jordan and Egypt
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_places_jordan_names_trgm ON places_jordan USING gist (name_ar gist_trgm_ops, name_en gist_trgm_ops);');
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_places_egypt_names_trgm ON places_egypt USING gist (name_ar gist_trgm_ops, name_en gist_trgm_ops);');
this.logger.log('Geocoding database triggers and optimized indexes initialized for Syria, Jordan, and Egypt.');
} catch (err) {
this.logger.error('Failed to initialize database geocoding triggers:', err);
}
+29 -2
View File
@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
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 { ApiKeyGuard } from '../common/guards/api-key.guard';
@@ -15,13 +15,15 @@ export class GeocodingController {
@ApiQuery({ name: 'lat', required: false, type: Number })
@ApiQuery({ name: 'lng', required: false, type: Number })
@ApiQuery({ name: 'radius', required: false, type: Number })
@ApiQuery({ name: 'country', required: false, enum: ['jordan', 'syria', 'egypt'] })
async search(
@Query('q') query: string,
@Query('lat') lat?: number,
@Query('lng') lng?: number,
@Query('radius') radius?: number,
@Query('country') country?: string,
) {
return this.geocodingService.searchPlaces(query, lat, lng, radius);
return this.geocodingService.searchPlaces(query, lat, lng, radius, country);
}
@Get('reverse')
@@ -38,6 +40,25 @@ export class GeocodingController {
return this.geocodingService.addPlace(placeData);
}
@Delete('places')
@ApiOperation({ summary: 'Delete a place by name or ID' })
@ApiQuery({ name: 'name', required: false })
@ApiQuery({ name: 'id', required: false, type: Number })
@ApiQuery({ name: 'country', required: true, enum: ['jordan', 'syria', 'egypt'] })
async deletePlace(
@Query('country') country: string,
@Query('name') name?: string,
@Query('id') id?: number,
) {
if (id) {
return this.geocodingService.deletePlaceById(Number(id), country);
}
if (name) {
return this.geocodingService.deletePlacesByName(name, country);
}
throw new HttpException('Name or ID required', HttpStatus.BAD_REQUEST);
}
@Post('upsert-place')
@ApiOperation({ summary: 'Add or Update a location (Automated Scraper)' })
async upsertPlace(@Body() placeData: any) {
@@ -55,4 +76,10 @@ export class GeocodingController {
async getPlaces(@Query('limit') limit?: number) {
return this.geocodingService.getRecentPlaces(limit);
}
@Get('geojson')
@ApiOperation({ summary: 'Get all user submitted places as GeoJSON for Map Style' })
async getGeoJSON() {
return this.geocodingService.getAllPlacesGeoJSON();
}
}
+144 -21
View File
@@ -45,44 +45,91 @@ export class GeocodingService {
return 'places_syria';
}
async searchPlaces(query: string, lat?: number, lon?: number, radius: number = 20000) {
async searchPlaces(
query: string,
lat?: number,
lon?: number,
radius: number = 20000,
country?: string,
) {
try {
const cleanQuery = query.trim();
const hasLocation = lat !== undefined && lon !== undefined;
// If we have no location, we search ALL three tables (Syria, Jordan, Egypt)
// وإلا فسنقوم بالبحث في المنطقة المناسبة فقط لزيادة السرعة والدقة
const tables = hasLocation ? [this.getTableNameForRepo(this.getRepositoryForCoords(lat, lon))] : ['places_syria', 'places_jordan', 'places_egypt'];
const allResults: any[] = [];
const ILikeQuery = `%${cleanQuery}%`;
const allResults: any[] = [];
// تحويل المسافة بالامتار الى درجات جغرافية تقريبية للفلترة السريعة
const radiusInDegrees = radius / 111000;
for (const tableName of tables) {
const repo = tableName === 'places_jordan' ? this.placesJordanRepository :
tableName === 'places_egypt' ? this.placesEgyptRepository :
this.placesSyriaRepository;
// 1. تحديد المنطقة (الأردن، سوريا، مصر)
let targetRegion = country?.toLowerCase();
if (!targetRegion && hasLocation) {
const repo = this.getRepositoryForCoords(lat!, lon!);
const tableName = this.getTableNameForRepo(repo);
targetRegion = tableName.replace('places_', '');
}
const userPlacesQuery = `
const userPointSql = hasLocation ? `ST_SetSRID(ST_MakePoint(${lon}, ${lat}), 4326)` : 'NULL';
// 2. البحث في جداول المستخدم (Syria, Egypt only)
const userTables = targetRegion
? (['syria', 'egypt'].includes(targetRegion) ? [`places_${targetRegion}`] : [])
: ['places_syria', 'places_egypt'];
for (const tableName of userTables) {
const repo = tableName === 'places_egypt' ? this.placesEgyptRepository : this.placesSyriaRepository;
const userQuery = `
SELECT id, name, name_ar, name_en, category, latitude, longitude, address, 'user_submitted' as source,
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3, $2), 4326)) ELSE 0 END as distance,
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ${userPointSql}) ELSE 0 END as distance,
similarity(COALESCE(name_ar, ''), $1) + similarity(COALESCE(name_en, ''), $1) as relevance
FROM ${tableName}
WHERE (name_ar % $1 OR name_en % $1 OR name % $1 OR name_ar ILIKE $4 OR name_en ILIKE $4)
${hasLocation ? `AND ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3, $2), 4326)) <= $5` : ''}
ORDER BY distance ASC, relevance DESC LIMIT 10
${hasLocation ? `AND location && ST_Expand(${userPointSql}, $5) AND ST_DistanceSphere(location, ${userPointSql}) <= $6` : ''}
ORDER BY relevance DESC, distance ASC LIMIT 15
`;
const params = [cleanQuery, lat || null, lon || null, ILikeQuery, radius];
const results = await repo.query(userPlacesQuery, params);
const results = await repo.query(userQuery, [cleanQuery, lat || null, lon || null, ILikeQuery, radiusInDegrees, radius]);
allResults.push(...results);
}
// Sort combined results by relevance and distance
const sortedResults = allResults.sort((a, b) => b.relevance - a.relevance || a.distance - b.distance).slice(0, 15);
// 3. البحث في قاعدة بيانات OSM (Jordan and Regional)
let osmBoundFilter = '';
if (targetRegion === 'jordan') {
osmBoundFilter = 'AND ST_Contains(ST_MakeEnvelope(34.5, 29.0, 39.5, 33.5, 4326), geom)';
} else if (targetRegion === 'syria') {
osmBoundFilter = 'AND ST_Contains(ST_MakeEnvelope(35.5, 32.3, 42.4, 37.5, 4326), geom)';
} 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, ${userPointSql}) 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(${userPointSql}, $5) AND ST_DistanceSphere(geom, ${userPointSql}) <= $6` : ''}
ORDER BY relevance DESC, distance ASC LIMIT 20
`;
const osmResults = await this.osmPointsRepository.query(osmQuery, [cleanQuery, lat || null, lon || null, ILikeQuery, radiusInDegrees, radius]);
allResults.push(...osmResults);
// 4. التصفية والترتيب النهائي (الأولوية للمسافة إذا كان الموقع معروفاً)
const sortedResults = allResults
.sort((a, b) => {
if (hasLocation) {
return (a.distance - b.distance) || (b.relevance - a.relevance);
}
return (b.relevance - a.relevance) || (a.distance - b.distance);
})
.slice(0, 20);
this.logger.debug(`Spatial Search complete (Sorted by ${hasLocation ? 'Distance' : 'Relevance'}). Results: ${sortedResults.length}`);
return { results: sortedResults };
} catch (e) {
this.logger.error('Search failed:', e);
this.logger.error('Optimized Spatial Search failed:', e);
return { results: [] };
}
}
@@ -190,4 +237,80 @@ export class GeocodingService {
.sort((a, b) => b.created_at.getTime() - a.created_at.getTime())
.slice(0, limit);
}
async getAllPlacesGeoJSON() {
try {
const query = `
SELECT id, name_ar as name, category, latitude, longitude, address, 'syria' as region
FROM places_syria WHERE (name_ar IS NOT NULL OR name IS NOT NULL) AND latitude IS NOT NULL
UNION ALL
SELECT id, name_ar as name, category, latitude, longitude, address, 'jordan' as region
FROM places_jordan WHERE (name_ar IS NOT NULL OR name IS NOT NULL) AND latitude IS NOT NULL
UNION ALL
SELECT id, name_ar as name, category, latitude, longitude, address, 'egypt' as region
FROM places_egypt WHERE (name_ar IS NOT NULL OR name IS NOT NULL) AND latitude IS NOT NULL
`;
const allResults = await this.placesSyriaRepository.query(query);
const features = allResults.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,
icon: p.category === 'mosque' ? 'mosque' : (p.category === 'hospital' ? 'hospital' : 'marker')
}
}));
return {
type: 'FeatureCollection',
features
};
} catch (e) {
this.logger.error('Failed to generate GeoJSON for all places:', e);
return { type: 'FeatureCollection', features: [] };
}
}
async deletePlacesByName(name: string, country: string) {
try {
const repo = this.getRepoByCountry(country);
const result = await repo.delete({ name_ar: name });
const resultEn = await repo.delete({ name_en: name });
const resultGeneric = await repo.delete({ name: name });
const totalAffected = (result.affected || 0) + (resultEn.affected || 0) + (resultGeneric.affected || 0);
this.logger.debug(`Deleted ${totalAffected} places named "${name}" in ${country}`);
return { success: true, affected: totalAffected };
} catch (e) {
this.logger.error(`Failed to delete places named "${name}" in ${country}:`, e);
throw new HttpException('Deletion failed', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
async deletePlaceById(id: number, country: string) {
try {
const repo = this.getRepoByCountry(country);
const result = await repo.delete(id);
this.logger.debug(`Deleted place ID ${id} in ${country}`);
return { success: true, affected: result.affected };
} catch (e) {
this.logger.error(`Failed to delete place ID ${id} in ${country}:`, e);
throw new HttpException('Deletion failed', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
private getRepoByCountry(country: string) {
switch (country?.toLowerCase()) {
case 'syria': return this.placesSyriaRepository;
case 'jordan': return this.placesJordanRepository;
case 'egypt': return this.placesEgyptRepository;
default: throw new HttpException('Invalid country: ' + country, HttpStatus.BAD_REQUEST);
}
}
}