Final Refinement: Native Labels, Interactive Management UI, and Visual Polishing
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user