تعلّم بناء منصة خرائط ذكية بتقنيات حقيقية.
هذا الدليل يأخذك في رحلة عملية داخل كود منصة "انطلق" — من تصميم قاعدة البيانات المكانية إلى نشر الحاويات. كل سطر كود هنا مأخوذ من المشروع الفعلي.
فلسفة NestJS — لماذا هذا الإطار؟
NestJS يقوم على ثلاثة مبادئ أساسية: الوحدات (Modules) لتنظيم الكود، المتحكمات (Controllers) لاستقبال الطلبات، والخدمات (Services) لمنطق العمل. كل شيء مربوط بـ حقن التبعيات (Dependency Injection).
نقطة البداية هي ملف main.ts — هنا يبدأ كل شيء:
import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); // تفعيل تبادل الموارد مع الواجهة الأمامية app.enableCors(); app.setGlobalPrefix('api'); // كل المسارات تبدأ بـ /api // إعداد وثائق Swagger التلقائية const config = new DocumentBuilder() .setTitle('Jordan Map Platform API') .setVersion('1.0').build(); const port = process.env.API_PORT || 3000; await app.listen(port); console.log(`🚀 API is running on port ${port}`); } bootstrap();
main.ts → يُنشئ التطبيق من AppModule → الذي يستورد TelemetryModule + MapsModule + GeocodingModule.الوحدات والتبعيات — كيف يُنظَّم الكود؟
المشروع مقسم لـ 4 وحدات رئيسية. كل وحدة تعريفها في ملف *.module.ts:
@Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), ScheduleModule.forRoot(), // لتشغيل المهام الدورية (Cron) TypeOrmModule.forRootAsync({ // اتصال قاعدة البيانات useFactory: (config) => ({ type: 'postgres', url: config.get('DATABASE_URL'), autoLoadEntities: true, }), }), TelemetryModule, // وحدة تتبع السائقين MapsModule, // وحدة التوجيه والخرائط GeocodingModule, // وحدة البحث المكاني ], }) export class AppModule {}
@Module({ imports: [ TypeOrmModule.forFeature([ // ← تسجيل الكيانات TelemetryLog, RoadSegmentStat, CandidateRoad ]), RedisModule, // ← استيراد خدمة Redis ], controllers: [TelemetryController, MapRefinementController], providers: [TelemetryService, TelemetryAnalyzerService, RedisService, ExternalTelemetryService], exports: [TelemetryService, TelemetryAnalyzerService], }) export class TelemetryModule {}
providers وأي خدمة تريد مشاركتها يجب وضعها في exports.الكيانات و TypeORM — تصميم قاعدة البيانات
كل جدول في PostgreSQL يُمثَّل بكيان (Entity). الكيان هو كلاس TypeScript مزيّن بـ @Entity() يُعرِّف الأعمدة والفهارس:
TelemetryLog
يخزن نقاط GPS من السائقين. يحتوي عمود location من نوع PostGIS geography(Point) مع فهرس مكاني.
RoadSegmentStat
إحصائيات السرعة لكل مقطع طريق. يحتوي congestionFactor وgeometry من نوع LineString.
CandidateRoad
طرق مُكتشفة من تحليل DBSCAN. تحتوي confidence (0-1) وstatus (pending/approved/rejected).
BasePlace
الكيان الأب لجداول places_syria, places_jordan, places_egypt. يُعرّف الأعمدة المشتركة.
@Entity('telemetry_logs') export class TelemetryLog { @PrimaryGeneratedColumn() id: number; @Column() @Index() driverId: string; @Column('decimal', { precision: 10, scale: 7 }) latitude: number; @Column('float') speed: number; // نقطة جغرافية PostGIS للبحث المكاني السريع @Column({ type: 'geography', spatialFeatureType: 'Point', srid: 4326 }) @Index({ spatial: true }) // ← فهرس GIST للبحث بالمسافة location: any; }
نظام البحث المكاني — Forward & Reverse Geocoding
الخدمة تبحث في 3 جداول إقليمية + جدول OSM العالمي. المفتاح: استخدام ::float في استعلامات SQL لتجنب خطأ "النتائج الفارغة":
// تحديد الجدول المناسب بناءً على الإحداثيات private getRepositoryForCoords(lat, lng) { if (lat >= 29 && lat <= 37.5 && lng >= 34.5) { if (lat > 32.5 && lng > 35.8) return placesSyriaRepo; return placesJordanRepo; } if (lat >= 22 && lat <= 32) return placesEgyptRepo; } // الاستعلام المكاني — لاحظ ::float في كل مكان const query = ` SELECT id, name_ar, latitude, longitude, ST_DistanceSphere( location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326) ) as distance FROM ${tableName} WHERE name_ar % $1 -- بحث تشابهي (Trigram) ORDER BY distance ASC LIMIT 15 `;
text بدلاً من float، مما يُنتج نتائج فارغة. الحل: إضافة ::float لكل مُعامل رقمي.تتبع السائقين — من GPS إلى قاعدة البيانات
كل 3 ثوانٍ، يرسل تطبيق السائق بيانات الموقع. المتحكم يستقبلها والخدمة تحفظها:
async ingest(data: DriverTelemetryDto) { const log = this.telemetryRepo.create({ driverId: data.driver_id, latitude: data.latitude, longitude: data.longitude, speed: data.speed, heading: data.heading, location: { type: 'Point', coordinates: [data.longitude, data.latitude], // GeoJSON: [lng, lat] }, }); await this.telemetryRepo.save(log); return { success: true, timestamp: new Date() }; }
Flutter App → POST /api/telemetry → TelemetryController.ingest() → TelemetryService.ingest() → PostgreSQL + PostGISدورة الذكاء المكاني — كل 10 أيام
هذا هو قلب المنصة. الخدمة TelemetryAnalyzerService تعمل تلقائياً عبر @Cron كل فجر لتحليل البيانات:
-- الخطوة 1: إيجاد نقاط بعيدة عن أي طريق معروف (> 15 متر) WITH off_road_points AS ( SELECT t.id, t.location, t.timestamp, t."driverId" FROM telemetry_logs t WHERE t.speed > 5 -- متحرك وليس متوقف AND NOT EXISTS ( SELECT 1 FROM planet_osm_line l WHERE ST_DWithin(t.location::geography, ST_Transform(l.way, 4326)::geography, 15) ) ), -- الخطوة 2: تجميع النقاط القريبة باستخدام DBSCAN clustered AS ( SELECT *, ST_ClusterDBSCAN(location::geometry, eps := 0.0003, minpoints := 5) OVER () AS cluster_id FROM off_road_points ) -- الخطوة 3: تحويل كل تجمع إلى خط طريق مرشح SELECT cluster_id, COUNT(DISTINCT "driverId") AS unique_drivers, ST_AsGeoJSON(ST_MakeLine(location ORDER BY timestamp)) AS geojson FROM clustered WHERE cluster_id IS NOT NULL GROUP BY cluster_id HAVING COUNT(DISTINCT "driverId") >= 2
مرجع الـ API — كل نقاط النهاية
جميع المسارات محمية بـ ApiKeyGuard عبر الترويسة x-api-key.
| الطريقة | المسار | الوصف | الملف المصدر |
|---|---|---|---|
| POST | /api/telemetry | استقبال بيانات تتبع السائقين كل 3 ثوانٍ | telemetry.controller.ts |
| POST | /api/telemetry/sync | مزامنة يدوية من الخادم الخارجي | telemetry.controller.ts |
| POST | /api/telemetry/process-intelligence | 🚀 تنفيذ دورة الذكاء كاملة (مزامنة + تحليل) | telemetry.controller.ts |
| GET | /api/maps/route?fromLat&fromLng&toLat&toLng | حساب أقصر مسار عبر GraphHopper | maps.controller.ts |
| GET | /api/geocoding/search?q&lat&lng&country | بحث مكاني متعدد الأقاليم | geocoding.controller.ts |
| GET | /api/geocoding/reverse?lat&lng | تحويل إحداثيات → عنوان | geocoding.controller.ts |
| POST | /api/geocoding/upsert-batch | إدخال أماكن بالجملة (Scraper) | geocoding.controller.ts |
| POST | /api/map-refinement/analyze-speeds | تحليل سرعات الطرق | map-refinement.controller.ts |
| POST | /api/map-refinement/discover-roads | اكتشاف طرق جديدة (DBSCAN) | map-refinement.controller.ts |
| GET | /api/map-refinement/congestion?north&south&east&west | بيانات الازدحام للخريطة الحرارية | map-refinement.controller.ts |
| PATCH | /api/map-refinement/candidates/:id/approve | الموافقة على طريق مكتشف | map-refinement.controller.ts |
| DELETE | /api/geocoding/places?country&name | حذف مكان حسب الاسم أو المعرّف | geocoding.controller.ts |
بيئة Docker — حاويات مترابطة
كل خدمة تعمل في حاوية معزولة. ملف docker-compose.yml يربطها عبر شبكة داخلية:
db (PostGIS)
postgis/postgis:15-3.3 — القلب. تخزين كل البيانات المكانية مع فهارس GIST.
api (NestJS)
node:20-alpine — العقل المدبر. يعالج الطلبات وينفذ التحليلات.
martin (Tiles)
maplibre/martin — يولّد Vector Tiles مباشرة من PostGIS بسرعة فائقة.
routing (GraphHopper)
يحسب أقصر المسارات باستخدام ملف OSM محدث + بيانات الازدحام.
redis (Cache)
redis:7-alpine — تخزين مؤقت لحالة الازدحام اللحظية.
web (React)
لوحة تحكم الخرائط التفاعلية مع عرض البيانات.
services: db: image: postgis/postgis:15-3.3 volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready"] api: build: ./infrastructure/docker/api/Dockerfile ports: ["3200:3200"] environment: - DATABASE_URL=postgresql://user:pass@db:5432/mapdb - REDIS_URL=redis://redis:6379 depends_on: db: { condition: service_healthy } redis: { condition: service_healthy } martin: image: maplibre/martin:latest ports: ["3202:3000"] command: postgresql://user:pass@db:5432/mapdb
$ docker-compose up -d --build — بناء وتشغيل كل الحاويات$ docker-compose ps — عرض حالة الحاويات$ docker-compose logs -f api — مراقبة سجلات الـ API
إعدادات NGINX — البوابة الأمامية
NGINX يعمل كـ Reverse Proxy — يستقبل كل الطلبات على المنفذ 80 ويوجهها للحاوية المناسبة:
server { listen 80; server_name portal.intaleq.map; # توجيه طلبات الـ API إلى حاوية NestJS location /api { proxy_pass http://api:3200; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # توجيه طلبات بلاط الخرائط إلى Martin location /tiles { proxy_pass http://martin:3000; } # توجيه طلبات التوجيه إلى GraphHopper location /routing { proxy_pass http://routing:8989; } }