|
|
import axios from 'axios'; |
|
|
import { logger } from '../utils/logger.js'; |
|
|
import type { NearestClinic, Location } from '../types/index.js'; |
|
|
import { |
|
|
getSpecialtiesForCondition, |
|
|
calculateSpecialtyMatchScore, |
|
|
type MedicalSpecialty |
|
|
} from '../utils/medical-specialty-mapper.js'; |
|
|
|
|
|
export interface HospitalCandidate extends NearestClinic { |
|
|
specialty_score?: number; |
|
|
specialties?: MedicalSpecialty[]; |
|
|
} |
|
|
|
|
|
export class MapsService { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async findNearestHospitals( |
|
|
location: Location, |
|
|
keyword: string = 'phòng khám bệnh viện', |
|
|
limit: number = 3 |
|
|
): Promise<HospitalCandidate[]> { |
|
|
try { |
|
|
logger.info('Searching for nearest clinic using OpenStreetMap...'); |
|
|
|
|
|
|
|
|
const radiusMeters = 5000; |
|
|
|
|
|
|
|
|
let amenityType = 'hospital'; |
|
|
if (keyword && keyword.toLowerCase().includes('phòng khám')) { |
|
|
amenityType = 'clinic'; |
|
|
} else if (keyword && (keyword.toLowerCase().includes('bệnh viện') || keyword.toLowerCase().includes('hospital'))) { |
|
|
amenityType = 'hospital'; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const query = ` |
|
|
[out:json][timeout:25]; |
|
|
( |
|
|
node["amenity"="${amenityType}"](around:${radiusMeters},${location.lat},${location.lng}); |
|
|
way["amenity"="${amenityType}"](around:${radiusMeters},${location.lat},${location.lng}); |
|
|
relation["amenity"="${amenityType}"](around:${radiusMeters},${location.lat},${location.lng}); |
|
|
); |
|
|
out center; |
|
|
`; |
|
|
|
|
|
|
|
|
const encodedQuery = encodeURIComponent(query); |
|
|
|
|
|
|
|
|
const overpassUrl = 'https://overpass-api.de/api/interpreter'; |
|
|
|
|
|
const response = await axios.post( |
|
|
overpassUrl, |
|
|
`data=${encodedQuery}`, |
|
|
{ |
|
|
headers: { |
|
|
'Content-Type': 'application/x-www-form-urlencoded' |
|
|
}, |
|
|
timeout: 30000 |
|
|
} |
|
|
); |
|
|
|
|
|
if (!response.data || !response.data.elements || response.data.elements.length === 0) { |
|
|
logger.warn('No clinics found nearby in OpenStreetMap'); |
|
|
return []; |
|
|
} |
|
|
|
|
|
|
|
|
const candidates: Array<{ element: any; distance: number }> = []; |
|
|
|
|
|
for (const element of response.data.elements) { |
|
|
let elementLat: number; |
|
|
let elementLng: number; |
|
|
|
|
|
|
|
|
if (element.type === 'node') { |
|
|
elementLat = element.lat; |
|
|
elementLng = element.lon; |
|
|
} else if (element.type === 'way' || element.type === 'relation') { |
|
|
|
|
|
elementLat = element.center?.lat || element.lat; |
|
|
elementLng = element.center?.lon || element.lon; |
|
|
} else { |
|
|
continue; |
|
|
} |
|
|
|
|
|
|
|
|
const distance = this.calculateDistance( |
|
|
location.lat, |
|
|
location.lng, |
|
|
elementLat, |
|
|
elementLng |
|
|
); |
|
|
|
|
|
candidates.push({ element, distance }); |
|
|
} |
|
|
|
|
|
|
|
|
candidates.sort((a, b) => a.distance - b.distance); |
|
|
const topCandidates = candidates.slice(0, limit); |
|
|
|
|
|
|
|
|
const hospitals: HospitalCandidate[] = topCandidates.map(({ element, distance }) => { |
|
|
|
|
|
const tags = element.tags || {}; |
|
|
const name = tags.name || tags['name:vi'] || tags['name:en'] || 'Bệnh viện/Khám bệnh'; |
|
|
|
|
|
|
|
|
const addressParts: string[] = []; |
|
|
if (tags['addr:street']) addressParts.push(tags['addr:street']); |
|
|
if (tags['addr:housenumber']) addressParts.push(tags['addr:housenumber']); |
|
|
if (tags['addr:city']) addressParts.push(tags['addr:city']); |
|
|
if (tags['addr:district']) addressParts.push(tags['addr:district']); |
|
|
const address = addressParts.length > 0 |
|
|
? addressParts.join(', ') |
|
|
: tags['addr:full'] || 'Địa chỉ không có sẵn'; |
|
|
|
|
|
return { |
|
|
name, |
|
|
distance_km: Math.round(distance * 10) / 10, |
|
|
address, |
|
|
rating: undefined |
|
|
}; |
|
|
}); |
|
|
|
|
|
logger.info(`Found ${hospitals.length} hospitals nearby`); |
|
|
return hospitals; |
|
|
} catch (error) { |
|
|
logger.error({ error }, 'OpenStreetMap Overpass API error'); |
|
|
return []; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async findNearestClinic( |
|
|
location: Location, |
|
|
keyword: string = 'phòng khám bệnh viện' |
|
|
): Promise<NearestClinic | null> { |
|
|
const hospitals = await this.findNearestHospitals(location, keyword, 1); |
|
|
return hospitals.length > 0 ? hospitals[0] : null; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async findBestMatchingHospital( |
|
|
location: Location, |
|
|
condition?: string, |
|
|
keyword: string = 'bệnh viện' |
|
|
): Promise<HospitalCandidate | null> { |
|
|
try { |
|
|
logger.info(`Finding best matching hospital${condition ? ` for condition: ${condition}` : ''}...`); |
|
|
|
|
|
|
|
|
const hospitals = await this.findNearestHospitals(location, keyword, 3); |
|
|
|
|
|
if (hospitals.length === 0) { |
|
|
logger.warn('No hospitals found nearby'); |
|
|
return null; |
|
|
} |
|
|
|
|
|
|
|
|
if (!condition || condition.trim() === '') { |
|
|
logger.info('No condition provided, returning nearest hospital'); |
|
|
return hospitals[0]; |
|
|
} |
|
|
|
|
|
|
|
|
const specialties = getSpecialtiesForCondition(condition); |
|
|
logger.info(`Condition "${condition}" maps to specialties: ${specialties.join(', ')}`); |
|
|
|
|
|
|
|
|
const scoredHospitals = hospitals.map(hospital => { |
|
|
const specialtyScore = calculateSpecialtyMatchScore(hospital.name, specialties); |
|
|
|
|
|
|
|
|
|
|
|
const distanceScore = Math.max(0, 1 - (hospital.distance_km / 5)); |
|
|
const combinedScore = (specialtyScore * 0.7) + (distanceScore * 0.3); |
|
|
|
|
|
return { |
|
|
...hospital, |
|
|
specialty_score: specialtyScore, |
|
|
specialties, |
|
|
combined_score: combinedScore |
|
|
}; |
|
|
}); |
|
|
|
|
|
|
|
|
scoredHospitals.sort((a, b) => { |
|
|
|
|
|
if (Math.abs(a.specialty_score! - b.specialty_score!) > 0.1) { |
|
|
return b.specialty_score! - a.specialty_score!; |
|
|
} |
|
|
|
|
|
return a.distance_km - b.distance_km; |
|
|
}); |
|
|
|
|
|
const bestMatch = scoredHospitals[0]; |
|
|
logger.info(`Best match: ${bestMatch.name} (specialty score: ${bestMatch.specialty_score?.toFixed(2)}, distance: ${bestMatch.distance_km}km)`); |
|
|
|
|
|
return bestMatch; |
|
|
} catch (error) { |
|
|
logger.error({ error }, 'Error finding best matching hospital'); |
|
|
|
|
|
const hospitals = await this.findNearestHospitals(location, keyword, 1); |
|
|
return hospitals.length > 0 ? hospitals[0] : null; |
|
|
} |
|
|
} |
|
|
|
|
|
private calculateDistance( |
|
|
lat1: number, |
|
|
lon1: number, |
|
|
lat2: number, |
|
|
lon2: number |
|
|
): number { |
|
|
|
|
|
const R = 6371; |
|
|
const dLat = this.deg2rad(lat2 - lat1); |
|
|
const dLon = this.deg2rad(lon2 - lon1); |
|
|
|
|
|
const a = |
|
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) + |
|
|
Math.cos(this.deg2rad(lat1)) * |
|
|
Math.cos(this.deg2rad(lat2)) * |
|
|
Math.sin(dLon / 2) * |
|
|
Math.sin(dLon / 2); |
|
|
|
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); |
|
|
const distance = R * c; |
|
|
|
|
|
return distance; |
|
|
} |
|
|
|
|
|
private deg2rad(deg: number): number { |
|
|
return deg * (Math.PI / 180); |
|
|
} |
|
|
} |
|
|
|
|
|
|