feat: implement geocoding evaluation scripts and enhance routing service with traffic-aware path processing

This commit is contained in:
Hamza-Ayed
2026-07-16 14:31:07 +03:00
parent 3cf87999f4
commit 3773ab56a4
20 changed files with 885 additions and 137 deletions
+2 -2
View File
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { createHash } from 'crypto';
import { ApiKey } from './entities/api-key.entity';
import { Tenant, TenantPlan } from './entities/tenant.entity';
import { Tenant, TenantPlan, RATE_LIMITS } from './entities/tenant.entity';
import { RedisService } from '../common/redis.service';
@Injectable()
@@ -48,7 +48,7 @@ export class AuthService {
const result = {
tenant: apiKey.tenant,
apiKey: apiKey,
rateLimit: apiKey.rateLimit || 100, // Default 100 req/min
rateLimit: apiKey.rateLimit || RATE_LIMITS[apiKey.tenant.plan] || 100, // Default to plan limit
};
// 4. Update Cache (TTL 1 hour)
@@ -13,6 +13,20 @@ export enum TenantRole {
ADMIN = 'ADMIN',
}
export const QUOTA_LIMITS: Record<TenantPlan, number> = {
[TenantPlan.FREE]: 8000,
[TenantPlan.STARTER]: 25000,
[TenantPlan.PRO]: 100000,
[TenantPlan.ENTERPRISE]: 500000,
};
export const RATE_LIMITS: Record<TenantPlan, number> = {
[TenantPlan.FREE]: 5,
[TenantPlan.STARTER]: 100,
[TenantPlan.PRO]: 500,
[TenantPlan.ENTERPRISE]: 50000,
};
@Entity('tenants')
export class Tenant {
@PrimaryGeneratedColumn('uuid')
@@ -33,6 +33,15 @@ export class GeocodingController {
return this.geocodingService.searchPlaces(q, lat, lng, radius, country);
}
@Get('autocomplete')
@ApiOperation({ summary: 'Fast autocomplete for live typing' })
@ApiQuery({ name: 'q', required: true })
@ApiQuery({ name: 'country', required: false })
async autocomplete(@Query('q') q: string, @Query('country') country?: string) {
if (!q || q.trim().length < 2) return { results: [] };
return this.geocodingService.autocomplete(q, country);
}
@Get('reverse')
@ApiOperation({ summary: 'Reverse Geocoding (Lat/Lng to Address)' })
async reverse(@Query() reverseDto: ReverseGeocodeDto) {
+3 -1
View File
@@ -19,6 +19,7 @@ import { MapRefinementService } from './map-refinement.service';
import { MapRefinementController } from './map-refinement.controller';
import { CacheModule } from '@nestjs/cache-manager';
import * as redisStore from 'cache-manager-redis-store';
import { IndexRefreshService } from './index-refresh.service';
@Module({
imports: [
@@ -47,7 +48,8 @@ import * as redisStore from 'cache-manager-redis-store';
AdminBoundariesService,
JordanResearchService,
AdministrativeLinkingService,
MapRefinementService
MapRefinementService,
IndexRefreshService
],
exports: [GeocodingService, MapRefinementService],
})
+226 -100
View File
@@ -65,8 +65,17 @@ export class GeocodingService {
async searchPlaces(query: string, lat?: number, lon?: number, radius: number = 20000, country?: string) {
try {
const cleanQuery = query.trim();
let cleanQuery = query.trim();
if (!cleanQuery) return { results: [] };
let relativePrefix = '';
const relativeQueryRegex = /^(قرب|بالقرب من|قريب من|عند|بجانب|جنب|حد|بجوار|مقابل|قبال|خلف|ورا|وراء)\s+(.+)$/i;
const match = cleanQuery.match(relativeQueryRegex);
if (match) {
relativePrefix = match[1];
cleanQuery = match[2];
}
const hasLocation = lat !== undefined && lon !== undefined;
const geoSegment = hasLocation ? `${lat!.toFixed(2)}_${lon!.toFixed(2)}` : 'global';
@@ -76,112 +85,110 @@ export class GeocodingService {
if (cached) return { results: cached, source: 'cache_hit' };
let targetRegion = country?.toLowerCase() || this.identifyRegion(lat, lon);
const primaryTables = targetRegion && ['syria', 'egypt', 'jordan'].includes(targetRegion)
? [`places_${targetRegion}`] : ['places_jordan', 'places_syria', 'places_egypt'];
// Normalize the search query using the DB function
const [normalizedQueryRes] = await this.osmPointsRepository.query(`SELECT normalize_arabic($1) as nq`, [cleanQuery]);
const normalizedQuery = normalizedQueryRes?.nq || cleanQuery.toLowerCase();
const queryPromises: Promise<any[]>[] = [];
let queryParams: any[] = [normalizedQuery];
let locationCondition = '';
if (hasLocation) {
locationCondition = `AND ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4`;
queryParams.push(lat, lon, radius);
}
primaryTables.forEach(tableName => {
const repo = this.getRepoByTableName(tableName);
queryPromises.push(repo.query(`
SELECT
p.id, p.name, p.name_ar, p.name_en, p.category,
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, 'user_place' as 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,
GREATEST(similarity(COALESCE(p.name_ar, ''), $1), similarity(COALESCE(p.name, ''), $1)) as relevance
FROM ${tableName} p
LEFT JOIN neighborhood_polygons n ON p.neighborhood_id = n.id
LEFT JOIN admin_boundaries d ON p.district_id = d.id
LEFT JOIN admin_boundaries g ON p.governorate_id = g.id
WHERE (p.name_ar % $1 OR p.name % $1)
AND ($2::float IS NULL OR ST_DistanceSphere(p.location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
ORDER BY (p.name_ar <-> $1) ASC LIMIT 10
`, [cleanQuery, lat || null, lon || null, radius]));
});
let regionCondition = '';
if (targetRegion && ['syria', 'jordan', 'egypt'].includes(targetRegion)) {
regionCondition = `AND (region = '${targetRegion}' OR region = 'global')`;
}
queryPromises.push(this.osmPointsRepository.query(`
const sqlQuery = `
SELECT
o.osm_id as id, o.name, o.name_ar, o.name_en, COALESCE(o.amenity, o.shop, 'place') as category,
o.latitude, o.longitude, o.addr_street as address, 'osm_global' as source,
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,
GREATEST(similarity(COALESCE(o.name, ''), $1), similarity(COALESCE(o.name_ar, ''), $1)) as relevance
FROM osm_points_with_area o
WHERE (o.name % $1 OR o.name_ar % $1)
AND ($2::float IS NULL OR ST_DistanceSphere(o.geom, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
ORDER BY (o.name <-> $1) ASC LIMIT 10
`, [cleanQuery, lat || null, lon || null, radius]));
queryPromises.push(this.osmPointsRepository.query(`
SELECT
id, name_ar as name, name_ar, name_en, 'admin' as category,
id, name, name_ar, category,
'' as neighbourhood, '' as district, '' as governorate,
ST_Y(ST_Centroid(geom))::text as latitude, ST_X(ST_Centroid(geom))::text as longitude, '' as address, 'admin_boundary' as region, 'admin' as source,
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,
similarity(COALESCE(name_ar, ''), $1) as relevance
FROM admin_boundaries
WHERE name_ar % $1
AND ($2::float IS NULL OR ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
ORDER BY (name_ar <-> $1) ASC LIMIT 5
`, [cleanQuery, lat || null, lon || null, radius]));
latitude, longitude, address, region, source, popularity_score,
${hasLocation ? 'ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326))' : '0'} as distance,
similarity(normalized_name, $1) as relevance
FROM unified_search_index
WHERE normalized_name % $1
${locationCondition}
${regionCondition}
ORDER BY (normalized_name <-> $1) ASC
LIMIT 50
`;
queryPromises.push(this.osmPointsRepository.query(`
SELECT
id::text, COALESCE(names->>'primary', names->>'common', 'Street') as name, COALESCE(names->>'primary', names->>'common', 'Street') as name_ar, '' as name_en, 'street' as category,
'' as neighbourhood, '' as district, '' as governorate,
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture' as region, 'overture_global' 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,
GREATEST(similarity(COALESCE(names->>'primary', ''), $1), similarity(COALESCE(names->>'common', ''), $1)) as relevance
FROM overture_segment
WHERE (names->>'primary' % $1 OR names->>'common' % $1)
AND ($2::float IS NULL OR ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
ORDER BY (COALESCE(names->>'primary', '') <-> $1) ASC LIMIT 10
`, [cleanQuery, lat || null, lon || null, radius]));
queryPromises.push(this.osmPointsRepository.query(`
SELECT
id::text, COALESCE(names->>'primary', names->>'common', 'Building') as name, COALESCE(names->>'primary', names->>'common', 'Building') as name_ar, '' as name_en, 'building' as category,
'' as neighbourhood, '' as district, '' as governorate,
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture' as region, 'overture_global' 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,
GREATEST(similarity(COALESCE(names->>'primary', ''), $1), similarity(COALESCE(names->>'common', ''), $1)) as relevance
FROM overture_building
WHERE (names->>'primary' % $1 OR names->>'common' % $1)
AND ($2::float IS NULL OR ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
ORDER BY (COALESCE(names->>'primary', '') <-> $1) ASC LIMIT 10
`, [cleanQuery, lat || null, lon || null, radius]));
queryPromises.push(this.osmPointsRepository.query(`
SELECT
id::text, COALESCE(names->>'primary', names->>'common', 'Place') as name, COALESCE(names->>'primary', names->>'common', 'Place') as name_ar, '' as name_en, 'place' as category,
'' as neighbourhood, '' as district, '' as governorate,
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture' as region, 'overture_global' 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,
GREATEST(similarity(COALESCE(names->>'primary', ''), $1), similarity(COALESCE(names->>'common', ''), $1)) as relevance
FROM overture_place
WHERE (names->>'primary' % $1 OR names->>'common' % $1)
AND ($2::float IS NULL OR ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
ORDER BY (COALESCE(names->>'primary', '') <-> $1) ASC LIMIT 10
`, [cleanQuery, lat || null, lon || null, radius]));
const executionResults = await Promise.race([
Promise.allSettled(queryPromises),
new Promise<any>((_, reject) => setTimeout(() => reject(new Error('QUERY_TIMEOUT')), this.DB_TIMEOUT_MS))
const allResults = await Promise.race([
this.osmPointsRepository.query(sqlQuery, queryParams),
new Promise<any[]>((_, reject) => setTimeout(() => reject(new Error('QUERY_TIMEOUT')), this.DB_TIMEOUT_MS))
]).catch(e => {
this.logger.warn(`Search optimization threshold hit: ${e.message}`);
return [] as any[];
});
let allResults: any[] = [];
if (Array.isArray(executionResults)) {
(executionResults as any[]).forEach(res => {
if (res.status === 'fulfilled' && res.value) allResults.push(...res.value);
});
const formatted = this.formatResults(allResults, hasLocation, relativePrefix);
// --- POI GATES (Clustering) ---
const placeIds = formatted.map(r => r.id);
if (placeIds.length > 0) {
try {
const gates = await this.osmPointsRepository.query(
`SELECT place_id, gate_name_ar, gate_name_en, latitude, longitude, is_main_gate
FROM place_gates
WHERE place_id = ANY($1)`,
[placeIds]
);
if (gates.length > 0) {
formatted.forEach(r => {
const placeGates = gates.filter((g: any) => g.place_id === r.id).map((g: any) => ({
name_ar: g.gate_name_ar,
name_en: g.gate_name_en,
latitude: parseFloat(g.latitude),
longitude: parseFloat(g.longitude),
is_main_gate: g.is_main_gate
}));
if (placeGates.length > 0) {
r.gates = placeGates;
}
});
}
} catch (err) {
this.logger.warn('Failed to fetch POI gates, table might not exist yet.');
}
}
// -----------------------------
const formatted = this.formatResults(allResults, hasLocation);
if (formatted.length > 0) {
await this.cacheManager.set(cacheKey, formatted, 3600000);
} else {
// Log zero-result query asynchronously
this.logFailedSearch(cleanQuery, normalizedQuery, targetRegion, lat, lon).catch(err => {
this.logger.error('Failed to log zero-result search:', err);
});
// --- DID YOU MEAN? (Safety Net) ---
try {
const whereClause = regionCondition ? `WHERE ${regionCondition.substring(4)}` : '';
const fallbackQuery = `
SELECT name_ar, name, (normalized_name <-> $1) as dist
FROM unified_search_index
${whereClause}
ORDER BY normalized_name <-> $1 ASC
LIMIT 1
`;
const suggestions = await this.osmPointsRepository.query(fallbackQuery, [normalizedQuery]);
if (suggestions.length > 0 && suggestions[0].dist < 0.6) {
return {
results: [],
did_you_mean: suggestions[0].name_ar || suggestions[0].name
};
}
} catch (err) {
this.logger.warn('Did You Mean fallback failed: ' + err.message);
}
// -----------------------------------
}
return { results: formatted };
@@ -191,14 +198,26 @@ export class GeocodingService {
}
}
private formatResults(results: any[], hasLocation: boolean) {
private formatResults(results: any[], hasLocation: boolean, relativePrefix: string = '') {
const seenStreets = new Set<string>();
// Normalize popularity to a 0-1 scale
const maxPopularity = Math.max(...results.map(r => r.popularity_score || 10), 100);
return results
.map(r => {
// Weighted scoring: 70% Name Similarity, 30% Geographic Proximity
// Weighted scoring:
// 50% Text Match (relevance)
// 30% Popularity
// 20% Geographic Proximity
const textScore = Number(r.relevance);
const popularityScore = (r.popularity_score || 10) / maxPopularity;
// Proximity bonus is 1.0 at 0m, decaying linearly to 0.0 at 10km.
const proximityBonus = hasLocation ? Math.max(0, 1 - (Number(r.distance) / 10000)) : 0;
const totalScore = (Number(r.relevance) * 0.7) + (proximityBonus * 0.3);
const totalScore = (textScore * 0.5) + (popularityScore * 0.3) + (proximityBonus * 0.2);
return { ...r, totalScore };
})
.sort((a, b) => b.totalScore - a.totalScore)
@@ -214,8 +233,12 @@ export class GeocodingService {
.map(r => {
const addressParts = [r.neighbourhood, r.district, r.governorate].filter(Boolean);
const full_address = addressParts.length > 0 ? addressParts.join('، ') : (r.address || '');
const nameAr = r.name_ar || r.name;
const displayName = relativePrefix ? `${relativePrefix} ${nameAr}` : nameAr;
return {
...r,
name: displayName,
name_ar: displayName,
latitude: parseFloat(r.latitude),
longitude: parseFloat(r.longitude),
distance_km: r.distance ? (Number(r.distance) / 1000).toFixed(2) : null,
@@ -232,6 +255,87 @@ export class GeocodingService {
return this.placesSyriaRepository;
}
/**
* Log zero-result queries to the failed_searches table for mining missing places.
*/
private async logFailedSearch(query: string, normalizedQuery: string, country?: string, lat?: number, lon?: number) {
const q = `
INSERT INTO failed_searches (query_text, normalized_query, country, latitude, longitude)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (normalized_query, COALESCE(country, 'global'))
DO UPDATE SET search_count = failed_searches.search_count + 1, last_seen_at = CURRENT_TIMESTAMP;
`;
await this.osmPointsRepository.query(q, [query, normalizedQuery, country || null, lat || null, lon || null]);
}
/**
* Fast Autocomplete using prefix matching
*/
async autocomplete(query: string, country?: string) {
try {
let cleanQuery = query.trim();
if (cleanQuery.length < 2) return { results: [] };
let relativePrefix = '';
const relativeQueryRegex = /^(قرب|بالقرب من|قريب من|عند|بجانب|جنب|حد|بجوار|مقابل|قبال|خلف|ورا|وراء)\s+(.+)$/i;
const match = cleanQuery.match(relativeQueryRegex);
if (match) {
relativePrefix = match[1];
cleanQuery = match[2];
if (cleanQuery.length < 2) return { results: [] };
}
const targetRegion = country?.toLowerCase();
const cacheKey = `geo_auto:${targetRegion || 'auto'}:${cleanQuery.toLowerCase()}`;
const cached: any = await this.cacheManager.get(cacheKey);
if (cached) return { results: cached, source: 'cache_hit' };
const [normalizedQueryRes] = await this.osmPointsRepository.query(`SELECT normalize_arabic($1) as nq`, [cleanQuery]);
const normalizedQuery = normalizedQueryRes?.nq || cleanQuery.toLowerCase();
let queryParams: any[] = [`${normalizedQuery}%`];
let regionCondition = '';
if (targetRegion && ['syria', 'jordan', 'egypt'].includes(targetRegion)) {
regionCondition = `AND (region = '${targetRegion}' OR region = 'global')`;
}
// Using the specialized btree index on varchar_pattern_ops
const sqlQuery = `
SELECT
id, name, name_ar, category, region, source, address
FROM unified_search_index
WHERE normalized_name LIKE $1
${regionCondition}
ORDER BY LENGTH(normalized_name) ASC
LIMIT 7
`;
const results = await this.osmPointsRepository.query(sqlQuery, queryParams);
const formatted = results.map(r => {
const nameAr = r.name_ar || r.name;
const displayName = relativePrefix ? `${relativePrefix} ${nameAr}` : nameAr;
return {
id: r.id,
name: displayName,
category: r.category,
region: r.region,
address: r.address
};
});
if (formatted.length > 0) {
await this.cacheManager.set(cacheKey, formatted, 3600000); // 1 hour
}
return { results: formatted };
} catch (e) {
this.logger.error('Autocomplete failed:', e);
return { results: [] };
}
}
async reverseGeocode(lat: number, lng: number) {
try {
const repo = this.getRepositoryForCoords(lat, lng);
@@ -307,11 +411,33 @@ export class GeocodingService {
return allResults
.sort((a, b) => Number(a.distance) - Number(b.distance))
.slice(0, 5)
.map(r => ({
...r,
latitude: parseFloat(r.latitude),
longitude: parseFloat(r.longitude)
}));
.map(r => {
const distance = Number(r.distance);
const fullAddressParts = [r.name_ar || r.name, r.address, r.neighbourhood, r.district, r.governorate].filter(Boolean);
let humanReadable = r.name_ar || r.name;
if (distance <= 20) {
// Very close: just the name
humanReadable = r.name_ar || r.name;
} else if (distance <= 70 && r.category !== 'street') {
// Close: "أمام [اسم المعلم]" (In front of)
const streetName = r.address ? `، ${r.address}` : '';
humanReadable = `أمام ${r.name_ar || r.name}${streetName}`;
} else if (r.category === 'street' || distance > 70) {
// Far or Street: "شارع كذا، الحي"
const streetPart = r.category === 'street' ? (r.name_ar || r.name) : (r.address || r.name_ar || r.name);
const districtPart = r.neighbourhood || r.district || '';
humanReadable = [streetPart, districtPart].filter(Boolean).join('، ');
}
return {
...r,
latitude: parseFloat(r.latitude),
longitude: parseFloat(r.longitude),
human_readable_address: humanReadable,
full_address: fullAddressParts.join('، ')
};
});
} catch (error) {
this.logger.error('Reverse geocoding error:', error);
return [];
@@ -0,0 +1,37 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
@Injectable()
export class IndexRefreshService {
private readonly logger = new Logger(IndexRefreshService.name);
constructor(
@InjectRepository(OsmPointWithArea)
private readonly repo: Repository<OsmPointWithArea>, // Use any repository to execute raw SQL
) {}
// Run every 5 minutes
@Cron(CronExpression.EVERY_5_MINUTES)
async handleCron() {
this.logger.log('Starting background refresh for unified_search_index...');
try {
// CONCURRENTLY allows reading while refreshing (requires a unique index on the view)
await this.repo.query('REFRESH MATERIALIZED VIEW CONCURRENTLY unified_search_index;');
this.logger.log('Successfully refreshed unified_search_index.');
} catch (error) {
this.logger.error('Failed to refresh unified_search_index', error);
// Fallback if CONCURRENTLY fails (e.g. unique index missing or view is totally unpopulated)
try {
this.logger.log('Attempting standard refresh (blocking)...');
await this.repo.query('REFRESH MATERIALIZED VIEW unified_search_index;');
this.logger.log('Successfully refreshed unified_search_index (standard).');
} catch (fallbackError) {
this.logger.error('Fallback standard refresh also failed', fallbackError);
}
}
}
}
+64 -16
View File
@@ -66,10 +66,10 @@ export class MapsService {
const payload: any = {
points: ghPoints,
profile: profile,
locale: locale,
locale: locale === 'en' ? 'ar' : locale, // Default to Arabic if not specified or fallback
calc_points: true,
points_encoded: true,
instructions: steps,
instructions: steps || true, // Always request instructions to extract route name
};
// ── Closure-Aware Routing ─────────────────────────────────────────────
@@ -164,25 +164,47 @@ export class MapsService {
const baseDuration = route.time / 1000;
const trafficAwareDuration = baseDuration * trafficFactor;
// Process alternative routes if any without breaking existing frontend variables
const altRoutes = paths.slice(1).map(alt => ({
distance: alt.distance,
duration: Math.round(alt.time / 1000),
points: alt.points,
bbox: alt.bbox,
instructions: alt.instructions // Include instructions for alternatives if requested
}));
// Process all paths to add metadata (Names, Tags)
const processedPaths = paths.map((p: any, index: number) => {
const pCoords = this.decodePolyline(p.points);
const pTrafficFactor = this.trafficGrid.getTrafficFactor(pCoords, hr, dow);
const pDuration = Math.round((p.time / 1000) * pTrafficFactor);
const routeName = this.getRouteName(p.instructions);
// Tags assignment
const tags: string[] = [];
if (index === 0) tags.push('FASTEST');
if (paths.length > 1) {
const isShortest = paths.every((other: any) => p.distance <= other.distance);
if (isShortest) tags.push('SHORTEST');
if (index > 0 && !isShortest) tags.push('ALTERNATIVE');
}
return {
routeName: routeName ? `عبر ${routeName}` : `المسار ${index + 1}`,
tags,
distance: p.distance,
duration: pDuration,
points: p.points,
bbox: p.bbox,
instructions: steps ? p.instructions : undefined
};
});
const mainRoute = processedPaths[0];
const altRoutes = processedPaths.slice(1);
return {
distance: route.distance,
duration: Math.round(baseDuration),
trafficAwareDuration: Math.round(trafficAwareDuration),
routeName: mainRoute.routeName,
tags: mainRoute.tags,
distance: mainRoute.distance,
duration: mainRoute.duration,
trafficFactor: Math.round(trafficFactor * 100) / 100,
startName,
endName,
points: route.points,
bbox: route.bbox,
instructions: route.instructions, // Added: turn-by-turn maneuvers
points: mainRoute.points,
bbox: mainRoute.bbox,
instructions: mainRoute.instructions,
alternatives: altRoutes
};
} catch (error) {
@@ -192,6 +214,32 @@ export class MapsService {
}
}
/**
* Extract the most significant street name from instructions to name the route.
*/
private getRouteName(instructions: any[]): string | null {
if (!instructions || instructions.length === 0) return null;
const streetDistances: Record<string, number> = {};
for (const inst of instructions) {
if (inst.street_name && inst.street_name.trim() !== '') {
streetDistances[inst.street_name] = (streetDistances[inst.street_name] || 0) + (inst.distance || 0);
}
}
let longestStreet: string | null = null;
let maxDist = 0;
for (const [street, dist] of Object.entries(streetDistances)) {
if (dist > maxDist) {
maxDist = dist;
longestStreet = street;
}
}
return longestStreet;
}
/**
* Manual decoder for Google Polyline algorithm (Server-side spatial matching)
*/
+1 -16
View File
@@ -9,22 +9,7 @@ import {
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { UsageService } from './usage.service';
import { TenantPlan } from '../auth/entities/tenant.entity';
// Quota Limits per Plan
const QUOTA_LIMITS: Record<TenantPlan, number> = {
[TenantPlan.FREE]: 8000,
[TenantPlan.STARTER]: 25000,
[TenantPlan.PRO]: 100000,
[TenantPlan.ENTERPRISE]: 500000,
};
const RATE_LIMITS: Record<TenantPlan, number> = {
[TenantPlan.FREE]: 5,
[TenantPlan.STARTER]: 100,
[TenantPlan.PRO]: 500,
[TenantPlan.ENTERPRISE]: 5000,
};
import { TenantPlan, QUOTA_LIMITS, RATE_LIMITS } from '../auth/entities/tenant.entity';
@Injectable()
export class UsageInterceptor implements NestInterceptor {