chore: save state before importing syria data
This commit is contained in:
@@ -0,0 +1,514 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>انطلق | الدليل الهندسي التعليمي الشامل</title>
|
||||
<meta name="description" content="دليل تعليمي شامل لمنصة انطلق - تعلم NestJS و PostGIS و Docker من خلال مشروع حقيقي">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{
|
||||
--bg:#0b0f1a;--bg2:#111827;--bg3:#1e293b;
|
||||
--fg:#f1f5f9;--muted:#94a3b8;--dim:#475569;
|
||||
--teal:#00f5d4;--gold:#fee440;--blue:#818cf8;
|
||||
--pink:#f472b6;--red:#fb7185;--green:#34d399;
|
||||
--card:#151e2e;--border:#1e3a5f;--code-bg:#0d1117;
|
||||
}
|
||||
body{font-family:'IBM Plex Sans Arabic',system-ui,sans-serif;background:var(--bg);color:var(--fg);line-height:1.9;overflow-x:hidden}
|
||||
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:var(--bg)}::-webkit-scrollbar-thumb{background:var(--teal);border-radius:10px}
|
||||
|
||||
/* Layout */
|
||||
.sidebar{position:fixed;top:0;right:0;width:300px;height:100vh;background:var(--bg2);border-left:1px solid var(--border);padding:1.5rem;z-index:100;overflow-y:auto;display:flex;flex-direction:column}
|
||||
.main{margin-right:300px;min-height:100vh}
|
||||
@media(max-width:1024px){.sidebar{display:none}.main{margin-right:0}}
|
||||
|
||||
/* Sidebar */
|
||||
.logo{display:flex;align-items:center;gap:.75rem;padding-bottom:1.5rem;border-bottom:1px solid var(--border);margin-bottom:1.5rem}
|
||||
.logo-icon{width:42px;height:42px;background:linear-gradient(135deg,var(--teal),var(--blue));border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:1.2rem;font-weight:900;color:var(--bg)}
|
||||
.logo h1{font-size:1.3rem;font-weight:700}.logo span{font-size:.65rem;color:var(--muted);display:block}
|
||||
.nav-group{margin-bottom:1.5rem}
|
||||
.nav-group-title{font-size:.65rem;text-transform:uppercase;letter-spacing:3px;color:var(--dim);font-weight:700;margin-bottom:.5rem;padding-right:.5rem}
|
||||
.nav-link{display:flex;align-items:center;gap:.6rem;padding:.6rem .75rem;border-radius:10px;color:var(--muted);text-decoration:none;font-size:.85rem;font-weight:500;transition:all .2s;border:1px solid transparent}
|
||||
.nav-link:hover,.nav-link.active{background:rgba(0,245,212,.06);color:var(--teal);border-color:rgba(0,245,212,.15)}
|
||||
.nav-link .num{font-family:'JetBrains Mono',monospace;font-size:.7rem;color:var(--dim);min-width:20px}
|
||||
|
||||
/* Hero */
|
||||
.hero{padding:5rem 4rem;background:linear-gradient(180deg,rgba(0,245,212,.03) 0%,transparent 60%);border-bottom:1px solid var(--border)}
|
||||
.hero .badge{display:inline-flex;align-items:center;gap:.5rem;background:rgba(0,245,212,.08);border:1px solid rgba(0,245,212,.2);border-radius:999px;padding:.35rem 1rem;font-size:.75rem;color:var(--teal);margin-bottom:1.5rem}
|
||||
.hero h1{font-size:3.2rem;font-weight:800;line-height:1.3;margin-bottom:1rem;max-width:700px}
|
||||
.hero h1 em{font-style:normal;background:linear-gradient(135deg,var(--teal),var(--blue));background-clip:text;-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
.hero p{font-size:1.1rem;color:var(--muted);max-width:600px;line-height:1.8}
|
||||
.hero-stats{display:flex;gap:2.5rem;margin-top:2.5rem;flex-wrap:wrap}
|
||||
.hero-stat{text-align:center}.hero-stat .val{font-size:2rem;font-weight:800;color:var(--teal)}.hero-stat .lbl{font-size:.75rem;color:var(--muted)}
|
||||
|
||||
/* Sections */
|
||||
.chapter{padding:4rem;border-bottom:1px solid var(--border);scroll-margin-top:2rem}
|
||||
.chapter-header{display:flex;align-items:center;gap:1rem;margin-bottom:2rem}
|
||||
.chapter-num{font-family:'JetBrains Mono',monospace;font-size:.75rem;color:var(--bg);background:var(--teal);width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-weight:700}
|
||||
.chapter h2{font-size:1.8rem;font-weight:800}
|
||||
.chapter p,.chapter li{color:var(--muted);font-size:.95rem}
|
||||
.chapter ul,.chapter ol{padding-right:1.5rem}
|
||||
.chapter li{margin-bottom:.5rem}
|
||||
|
||||
/* Code Blocks */
|
||||
.code-card{background:var(--code-bg);border:1px solid var(--border);border-radius:16px;overflow:hidden;margin:1.5rem 0}
|
||||
.code-header{display:flex;align-items:center;justify-content:space-between;padding:.6rem 1rem;background:rgba(255,255,255,.03);border-bottom:1px solid var(--border);font-size:.75rem}
|
||||
.code-header .file{color:var(--muted);font-family:'JetBrains Mono',monospace;display:flex;align-items:center;gap:.5rem}
|
||||
.code-header .file .dot{width:8px;height:8px;border-radius:50%}
|
||||
.code-header .lang{color:var(--teal);font-family:'JetBrains Mono',monospace;font-weight:600}
|
||||
pre.code{padding:1.25rem;font-family:'JetBrains Mono',monospace;font-size:.8rem;line-height:1.8;color:#c9d1d9;direction:ltr;text-align:left;overflow-x:auto;margin:0}
|
||||
.hl-key{color:#ff7b72}.hl-str{color:#a5d6ff}.hl-fn{color:#d2a8ff}.hl-cmt{color:#8b949e;font-style:italic}.hl-dec{color:#79c0ff}.hl-type{color:#ffa657}.hl-num{color:var(--teal)}
|
||||
|
||||
/* Info boxes */
|
||||
.callout{padding:1.25rem 1.5rem;border-radius:12px;margin:1.5rem 0;border-right:4px solid;font-size:.9rem}
|
||||
.callout.info{background:rgba(129,140,248,.06);border-color:var(--blue);color:var(--blue)}
|
||||
.callout.tip{background:rgba(0,245,212,.06);border-color:var(--teal)}
|
||||
.callout.warn{background:rgba(254,228,64,.06);border-color:var(--gold);color:var(--gold)}
|
||||
.callout strong{display:block;margin-bottom:.3rem}
|
||||
|
||||
/* Grid Cards */
|
||||
.card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1.25rem;margin:1.5rem 0}
|
||||
.card{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:1.5rem;transition:all .3s}
|
||||
.card:hover{border-color:var(--teal);transform:translateY(-4px);box-shadow:0 12px 40px rgba(0,0,0,.4)}
|
||||
.card .icon{font-size:1.5rem;margin-bottom:.75rem;display:block}
|
||||
.card h4{font-size:1rem;font-weight:700;margin-bottom:.5rem}
|
||||
.card p{font-size:.85rem;color:var(--muted)}
|
||||
|
||||
/* Diagram */
|
||||
.diagram{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:2rem;margin:1.5rem 0;text-align:center}
|
||||
.diagram-flow{display:flex;align-items:center;justify-content:center;gap:.5rem;flex-wrap:wrap;margin:1rem 0}
|
||||
.diagram-node{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:.75rem 1.25rem;font-size:.8rem;font-weight:600;min-width:100px}
|
||||
.diagram-node.active{border-color:var(--teal);color:var(--teal);box-shadow:0 0 20px rgba(0,245,212,.15)}
|
||||
.diagram-arrow{color:var(--dim);font-size:1.2rem}
|
||||
|
||||
/* API Table */
|
||||
.api-table{width:100%;border-collapse:collapse;margin:1.5rem 0;font-size:.85rem}
|
||||
.api-table th{text-align:right;padding:.75rem 1rem;background:var(--card);color:var(--teal);font-size:.7rem;text-transform:uppercase;letter-spacing:1px;border-bottom:2px solid var(--border)}
|
||||
.api-table td{padding:.75rem 1rem;border-bottom:1px solid rgba(255,255,255,.05);color:var(--muted);vertical-align:top}
|
||||
.api-table tr:hover td{background:rgba(255,255,255,.02)}
|
||||
.method{font-family:'JetBrains Mono',monospace;font-size:.75rem;font-weight:700;padding:.15rem .5rem;border-radius:4px}
|
||||
.method.get{color:#34d399;background:rgba(52,211,153,.1)}.method.post{color:#818cf8;background:rgba(129,140,248,.1)}.method.patch{color:#fbbf24;background:rgba(251,191,36,.1)}.method.delete{color:#fb7185;background:rgba(251,113,133,.1)}
|
||||
.endpoint{font-family:'JetBrains Mono',monospace;font-size:.8rem;color:var(--fg);direction:ltr;display:inline-block}
|
||||
|
||||
/* Footer */
|
||||
footer{padding:3rem 4rem;text-align:center;border-top:1px solid var(--border);color:var(--dim);font-size:.8rem}
|
||||
|
||||
/* Animations */
|
||||
.fade-in{opacity:0;transform:translateY(25px);transition:all .7s cubic-bezier(.16,1,.3,1)}
|
||||
.fade-in.visible{opacity:1;transform:translateY(0)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<div class="logo-icon">🧭</div>
|
||||
<div><h1>انطلق</h1><span>الدليل الهندسي التعليمي v3.0</span></div>
|
||||
</div>
|
||||
<nav>
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-title">الأساسيات</div>
|
||||
<a href="#hero" class="nav-link active"><span class="num">00</span> نظرة عامة</a>
|
||||
<a href="#nestjs-paradigm" class="nav-link"><span class="num">01</span> فلسفة NestJS</a>
|
||||
<a href="#modules" class="nav-link"><span class="num">02</span> الوحدات والتبعيات</a>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-title">قاعدة البيانات</div>
|
||||
<a href="#entities" class="nav-link"><span class="num">03</span> الكيانات و TypeORM</a>
|
||||
<a href="#geocoding" class="nav-link"><span class="num">04</span> نظام البحث المكاني</a>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-title">الذكاء المكاني</div>
|
||||
<a href="#telemetry" class="nav-link"><span class="num">05</span> تتبع السائقين</a>
|
||||
<a href="#intelligence" class="nav-link"><span class="num">06</span> دورة الـ 10 أيام</a>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-title">البنية التحتية</div>
|
||||
<a href="#api-reference" class="nav-link"><span class="num">07</span> مرجع الـ API</a>
|
||||
<a href="#docker" class="nav-link"><span class="num">08</span> بيئة Docker</a>
|
||||
<a href="#nginx" class="nav-link"><span class="num">09</span> إعدادات NGINX</a>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main">
|
||||
|
||||
<!-- Hero -->
|
||||
<header class="hero fade-in" id="hero">
|
||||
<div class="badge">📘 دليل تعليمي تفاعلي — من الصفر إلى الإنتاج</div>
|
||||
<h1>تعلّم بناء <em>منصة خرائط ذكية</em> بتقنيات حقيقية.</h1>
|
||||
<p>هذا الدليل يأخذك في رحلة عملية داخل كود منصة "انطلق" — من تصميم قاعدة البيانات المكانية إلى نشر الحاويات. كل سطر كود هنا مأخوذ من المشروع الفعلي.</p>
|
||||
<div class="hero-stats">
|
||||
<div class="hero-stat"><div class="val">4</div><div class="lbl">وحدات NestJS</div></div>
|
||||
<div class="hero-stat"><div class="val">6</div><div class="lbl">حاويات Docker</div></div>
|
||||
<div class="hero-stat"><div class="val">3</div><div class="lbl">دول مدعومة</div></div>
|
||||
<div class="hero-stat"><div class="val">∞</div><div class="lbl">نقاط تتبع</div></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Chapter 01: NestJS Paradigm -->
|
||||
<section class="chapter fade-in" id="nestjs-paradigm">
|
||||
<div class="chapter-header"><div class="chapter-num">01</div><h2>فلسفة NestJS — لماذا هذا الإطار؟</h2></div>
|
||||
<p>NestJS يقوم على ثلاثة مبادئ أساسية: <strong>الوحدات (Modules)</strong> لتنظيم الكود، <strong>المتحكمات (Controllers)</strong> لاستقبال الطلبات، و<strong>الخدمات (Services)</strong> لمنطق العمل. كل شيء مربوط بـ <strong>حقن التبعيات (Dependency Injection)</strong>.</p>
|
||||
|
||||
<div class="callout info"><strong>💡 المبدأ الأساسي</strong> كل وحدة (Module) هي صندوق مستقل يحتوي على متحكماته وخدماته. الوحدة لا تعرف شيئاً عن الوحدات الأخرى إلا إذا تم "تصديرها" (Export) و"استيرادها" (Import) بشكل صريح.</div>
|
||||
|
||||
<p style="margin-top:1rem">نقطة البداية هي ملف <code style="color:var(--teal)">main.ts</code> — هنا يبدأ كل شيء:</p>
|
||||
<div class="code-card">
|
||||
<div class="code-header"><div class="file"><span class="dot" style="background:var(--teal)"></span> main.ts</div><div class="lang">TypeScript</div></div>
|
||||
<pre class="code"><span class="hl-key">import</span> { NestFactory } <span class="hl-key">from</span> <span class="hl-str">'@nestjs/core'</span>;
|
||||
<span class="hl-key">import</span> { DocumentBuilder, SwaggerModule } <span class="hl-key">from</span> <span class="hl-str">'@nestjs/swagger'</span>;
|
||||
<span class="hl-key">import</span> { AppModule } <span class="hl-key">from</span> <span class="hl-str">'./app.module'</span>;
|
||||
|
||||
<span class="hl-key">async function</span> <span class="hl-fn">bootstrap</span>() {
|
||||
<span class="hl-key">const</span> app = <span class="hl-key">await</span> NestFactory.<span class="hl-fn">create</span>(AppModule);
|
||||
|
||||
<span class="hl-cmt">// تفعيل تبادل الموارد مع الواجهة الأمامية</span>
|
||||
app.<span class="hl-fn">enableCors</span>();
|
||||
app.<span class="hl-fn">setGlobalPrefix</span>(<span class="hl-str">'api'</span>); <span class="hl-cmt">// كل المسارات تبدأ بـ /api</span>
|
||||
|
||||
<span class="hl-cmt">// إعداد وثائق Swagger التلقائية</span>
|
||||
<span class="hl-key">const</span> config = <span class="hl-key">new</span> <span class="hl-fn">DocumentBuilder</span>()
|
||||
.<span class="hl-fn">setTitle</span>(<span class="hl-str">'Jordan Map Platform API'</span>)
|
||||
.<span class="hl-fn">setVersion</span>(<span class="hl-str">'1.0'</span>).<span class="hl-fn">build</span>();
|
||||
|
||||
<span class="hl-key">const</span> port = process.env.API_PORT || <span class="hl-num">3000</span>;
|
||||
<span class="hl-key">await</span> app.<span class="hl-fn">listen</span>(port);
|
||||
console.<span class="hl-fn">log</span>(<span class="hl-str">`🚀 API is running on port ${port}`</span>);
|
||||
}
|
||||
<span class="hl-fn">bootstrap</span>();</pre>
|
||||
</div>
|
||||
|
||||
<div class="callout tip"><strong>🔗 العلاقة:</strong> <code>main.ts</code> → يُنشئ التطبيق من <code>AppModule</code> → الذي يستورد <code>TelemetryModule</code> + <code>MapsModule</code> + <code>GeocodingModule</code>.</div>
|
||||
</section>
|
||||
|
||||
<!-- Chapter 02: Modules -->
|
||||
<section class="chapter fade-in" id="modules">
|
||||
<div class="chapter-header"><div class="chapter-num">02</div><h2>الوحدات والتبعيات — كيف يُنظَّم الكود؟</h2></div>
|
||||
<p>المشروع مقسم لـ 4 وحدات رئيسية. كل وحدة تعريفها في ملف <code style="color:var(--teal)">*.module.ts</code>:</p>
|
||||
|
||||
<div class="diagram">
|
||||
<div style="color:var(--muted);font-size:.8rem;margin-bottom:1rem">🏗️ خريطة الوحدات</div>
|
||||
<div class="diagram-flow">
|
||||
<div class="diagram-node active">AppModule</div>
|
||||
<div class="diagram-arrow">←</div>
|
||||
<div class="diagram-node">TelemetryModule</div>
|
||||
<div class="diagram-arrow">←</div>
|
||||
<div class="diagram-node">MapsModule</div>
|
||||
<div class="diagram-arrow">←</div>
|
||||
<div class="diagram-node">GeocodingModule</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-card">
|
||||
<div class="code-header"><div class="file"><span class="dot" style="background:var(--blue)"></span> app.module.ts</div><div class="lang">TypeScript</div></div>
|
||||
<pre class="code"><span class="hl-key">@Module</span>({
|
||||
<span class="hl-type">imports</span>: [
|
||||
ConfigModule.<span class="hl-fn">forRoot</span>({ isGlobal: <span class="hl-num">true</span> }),
|
||||
ScheduleModule.<span class="hl-fn">forRoot</span>(), <span class="hl-cmt">// لتشغيل المهام الدورية (Cron)</span>
|
||||
TypeOrmModule.<span class="hl-fn">forRootAsync</span>({ <span class="hl-cmt">// اتصال قاعدة البيانات</span>
|
||||
<span class="hl-type">useFactory</span>: (config) => ({
|
||||
type: <span class="hl-str">'postgres'</span>,
|
||||
url: config.<span class="hl-fn">get</span>(<span class="hl-str">'DATABASE_URL'</span>),
|
||||
autoLoadEntities: <span class="hl-num">true</span>,
|
||||
}),
|
||||
}),
|
||||
TelemetryModule, <span class="hl-cmt">// وحدة تتبع السائقين</span>
|
||||
MapsModule, <span class="hl-cmt">// وحدة التوجيه والخرائط</span>
|
||||
GeocodingModule, <span class="hl-cmt">// وحدة البحث المكاني</span>
|
||||
],
|
||||
})
|
||||
<span class="hl-key">export class</span> <span class="hl-type">AppModule</span> {}</pre>
|
||||
</div>
|
||||
|
||||
<div class="code-card">
|
||||
<div class="code-header"><div class="file"><span class="dot" style="background:var(--gold)"></span> telemetry.module.ts</div><div class="lang">TypeScript</div></div>
|
||||
<pre class="code"><span class="hl-key">@Module</span>({
|
||||
<span class="hl-type">imports</span>: [
|
||||
TypeOrmModule.<span class="hl-fn">forFeature</span>([ <span class="hl-cmt">// ← تسجيل الكيانات</span>
|
||||
TelemetryLog,
|
||||
RoadSegmentStat,
|
||||
CandidateRoad
|
||||
]),
|
||||
RedisModule, <span class="hl-cmt">// ← استيراد خدمة Redis</span>
|
||||
],
|
||||
<span class="hl-type">controllers</span>: [TelemetryController, MapRefinementController],
|
||||
<span class="hl-type">providers</span>: [TelemetryService, TelemetryAnalyzerService,
|
||||
RedisService, ExternalTelemetryService],
|
||||
<span class="hl-type">exports</span>: [TelemetryService, TelemetryAnalyzerService],
|
||||
})
|
||||
<span class="hl-key">export class</span> <span class="hl-type">TelemetryModule</span> {}</pre>
|
||||
</div>
|
||||
|
||||
<div class="callout warn"><strong>⚠️ قاعدة ذهبية:</strong> أي خدمة (Service) يجب أن تكون مُسجلة في <code>providers</code> وأي خدمة تريد مشاركتها يجب وضعها في <code>exports</code>.</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- Chapter 03: Entities -->
|
||||
<section class="chapter fade-in" id="entities">
|
||||
<div class="chapter-header"><div class="chapter-num">03</div><h2>الكيانات و TypeORM — تصميم قاعدة البيانات</h2></div>
|
||||
<p>كل جدول في PostgreSQL يُمثَّل بكيان (Entity). الكيان هو كلاس TypeScript مزيّن بـ <code style="color:var(--teal)">@Entity()</code> يُعرِّف الأعمدة والفهارس:</p>
|
||||
|
||||
<div class="card-grid">
|
||||
<div class="card"><div class="icon">📍</div><h4>TelemetryLog</h4><p>يخزن نقاط GPS من السائقين. يحتوي عمود <code>location</code> من نوع PostGIS <code>geography(Point)</code> مع فهرس مكاني.</p></div>
|
||||
<div class="card"><div class="icon">🛣️</div><h4>RoadSegmentStat</h4><p>إحصائيات السرعة لكل مقطع طريق. يحتوي <code>congestionFactor</code> و<code>geometry</code> من نوع <code>LineString</code>.</p></div>
|
||||
<div class="card"><div class="icon">🔍</div><h4>CandidateRoad</h4><p>طرق مُكتشفة من تحليل DBSCAN. تحتوي <code>confidence</code> (0-1) و<code>status</code> (pending/approved/rejected).</p></div>
|
||||
<div class="card"><div class="icon">🌍</div><h4>BasePlace</h4><p>الكيان الأب لجداول <code>places_syria</code>, <code>places_jordan</code>, <code>places_egypt</code>. يُعرّف الأعمدة المشتركة.</p></div>
|
||||
</div>
|
||||
|
||||
<div class="code-card">
|
||||
<div class="code-header"><div class="file"><span class="dot" style="background:var(--pink)"></span> telemetry.entity.ts</div><div class="lang">TypeScript</div></div>
|
||||
<pre class="code"><span class="hl-key">@Entity</span>(<span class="hl-str">'telemetry_logs'</span>)
|
||||
<span class="hl-key">export class</span> <span class="hl-type">TelemetryLog</span> {
|
||||
<span class="hl-key">@PrimaryGeneratedColumn</span>()
|
||||
id: <span class="hl-type">number</span>;
|
||||
|
||||
<span class="hl-key">@Column</span>() <span class="hl-key">@Index</span>()
|
||||
driverId: <span class="hl-type">string</span>;
|
||||
|
||||
<span class="hl-key">@Column</span>(<span class="hl-str">'decimal'</span>, { precision: <span class="hl-num">10</span>, scale: <span class="hl-num">7</span> })
|
||||
latitude: <span class="hl-type">number</span>;
|
||||
|
||||
<span class="hl-key">@Column</span>(<span class="hl-str">'float'</span>)
|
||||
speed: <span class="hl-type">number</span>;
|
||||
|
||||
<span class="hl-cmt">// نقطة جغرافية PostGIS للبحث المكاني السريع</span>
|
||||
<span class="hl-key">@Column</span>({ type: <span class="hl-str">'geography'</span>, spatialFeatureType: <span class="hl-str">'Point'</span>, srid: <span class="hl-num">4326</span> })
|
||||
<span class="hl-key">@Index</span>({ spatial: <span class="hl-num">true</span> }) <span class="hl-cmt">// ← فهرس GIST للبحث بالمسافة</span>
|
||||
location: <span class="hl-type">any</span>;
|
||||
}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Chapter 04: Geocoding -->
|
||||
<section class="chapter fade-in" id="geocoding">
|
||||
<div class="chapter-header"><div class="chapter-num">04</div><h2>نظام البحث المكاني — Forward & Reverse Geocoding</h2></div>
|
||||
<p>الخدمة تبحث في 3 جداول إقليمية + جدول OSM العالمي. المفتاح: استخدام <code style="color:var(--teal)">::float</code> في استعلامات SQL لتجنب خطأ "النتائج الفارغة":</p>
|
||||
|
||||
<div class="code-card">
|
||||
<div class="code-header"><div class="file"><span class="dot" style="background:var(--green)"></span> geocoding.service.ts — searchPlaces()</div><div class="lang">SQL + TypeScript</div></div>
|
||||
<pre class="code"><span class="hl-cmt">// تحديد الجدول المناسب بناءً على الإحداثيات</span>
|
||||
<span class="hl-key">private</span> <span class="hl-fn">getRepositoryForCoords</span>(lat, lng) {
|
||||
<span class="hl-key">if</span> (lat >= <span class="hl-num">29</span> && lat <= <span class="hl-num">37.5</span> && lng >= <span class="hl-num">34.5</span>) {
|
||||
<span class="hl-key">if</span> (lat > <span class="hl-num">32.5</span> && lng > <span class="hl-num">35.8</span>) <span class="hl-key">return</span> placesSyriaRepo;
|
||||
<span class="hl-key">return</span> placesJordanRepo;
|
||||
}
|
||||
<span class="hl-key">if</span> (lat >= <span class="hl-num">22</span> && lat <= <span class="hl-num">32</span>) <span class="hl-key">return</span> placesEgyptRepo;
|
||||
}
|
||||
|
||||
<span class="hl-cmt">// الاستعلام المكاني — لاحظ ::float في كل مكان</span>
|
||||
<span class="hl-key">const</span> query = <span class="hl-str">`
|
||||
SELECT id, name_ar, latitude, longitude,
|
||||
ST_DistanceSphere(
|
||||
location,
|
||||
ST_SetSRID(ST_MakePoint($3<span class="hl-dec">::float</span>, $2<span class="hl-dec">::float</span>), 4326)
|
||||
) as distance
|
||||
FROM ${tableName}
|
||||
WHERE name_ar % $1 -- بحث تشابهي (Trigram)
|
||||
ORDER BY distance ASC LIMIT 15
|
||||
`</span>;</pre>
|
||||
</div>
|
||||
<div class="callout tip"><strong>🐛 الخطأ الذي حللناه:</strong> PostGIS كان يقرأ الإحداثيات كـ <code>text</code> بدلاً من <code>float</code>، مما يُنتج نتائج فارغة. الحل: إضافة <code>::float</code> لكل مُعامل رقمي.</div>
|
||||
</section>
|
||||
|
||||
<!-- Chapter 05: Telemetry -->
|
||||
<section class="chapter fade-in" id="telemetry">
|
||||
<div class="chapter-header"><div class="chapter-num">05</div><h2>تتبع السائقين — من GPS إلى قاعدة البيانات</h2></div>
|
||||
<p>كل 3 ثوانٍ، يرسل تطبيق السائق بيانات الموقع. المتحكم يستقبلها والخدمة تحفظها:</p>
|
||||
|
||||
<div class="code-card">
|
||||
<div class="code-header"><div class="file"><span class="dot" style="background:var(--gold)"></span> telemetry.service.ts — ingest()</div><div class="lang">TypeScript</div></div>
|
||||
<pre class="code"><span class="hl-key">async</span> <span class="hl-fn">ingest</span>(data: DriverTelemetryDto) {
|
||||
<span class="hl-key">const</span> log = <span class="hl-key">this</span>.telemetryRepo.<span class="hl-fn">create</span>({
|
||||
driverId: data.driver_id,
|
||||
latitude: data.latitude, longitude: data.longitude,
|
||||
speed: data.speed, heading: data.heading,
|
||||
location: {
|
||||
type: <span class="hl-str">'Point'</span>,
|
||||
coordinates: [data.longitude, data.latitude], <span class="hl-cmt">// GeoJSON: [lng, lat]</span>
|
||||
},
|
||||
});
|
||||
<span class="hl-key">await this</span>.telemetryRepo.<span class="hl-fn">save</span>(log);
|
||||
<span class="hl-key">return</span> { success: <span class="hl-num">true</span>, timestamp: <span class="hl-key">new</span> Date() };
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div class="callout info"><strong>🔗 سلسلة الاستدعاء:</strong> <code>Flutter App</code> → <code>POST /api/telemetry</code> → <code>TelemetryController.ingest()</code> → <code>TelemetryService.ingest()</code> → <code>PostgreSQL + PostGIS</code></div>
|
||||
</section>
|
||||
|
||||
<!-- Chapter 06: Intelligence Cycle -->
|
||||
<section class="chapter fade-in" id="intelligence">
|
||||
<div class="chapter-header"><div class="chapter-num">06</div><h2>دورة الذكاء المكاني — كل 10 أيام</h2></div>
|
||||
<p>هذا هو قلب المنصة. الخدمة <code style="color:var(--teal)">TelemetryAnalyzerService</code> تعمل تلقائياً عبر <code>@Cron</code> كل فجر لتحليل البيانات:</p>
|
||||
|
||||
<div class="diagram">
|
||||
<div style="color:var(--muted);font-size:.8rem;margin-bottom:1rem">🧠 دورة المعالجة الذكية</div>
|
||||
<div class="diagram-flow">
|
||||
<div class="diagram-node">1. مزامنة البيانات</div>
|
||||
<div class="diagram-arrow">→</div>
|
||||
<div class="diagram-node active">2. تحليل السرعات</div>
|
||||
<div class="diagram-arrow">→</div>
|
||||
<div class="diagram-node">3. اكتشاف الطرق</div>
|
||||
<div class="diagram-arrow">→</div>
|
||||
<div class="diagram-node">4. تحديث Redis</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-card">
|
||||
<div class="code-header"><div class="file"><span class="dot" style="background:var(--red)"></span> telemetry-analyzer.service.ts — discoverNewRoads()</div><div class="lang">PostGIS SQL</div></div>
|
||||
<pre class="code"><span class="hl-cmt">-- الخطوة 1: إيجاد نقاط بعيدة عن أي طريق معروف (> 15 متر)</span>
|
||||
<span class="hl-key">WITH</span> off_road_points <span class="hl-key">AS</span> (
|
||||
<span class="hl-key">SELECT</span> t.id, t.location, t.timestamp, t."driverId"
|
||||
<span class="hl-key">FROM</span> telemetry_logs t
|
||||
<span class="hl-key">WHERE</span> t.speed > <span class="hl-num">5</span> <span class="hl-cmt">-- متحرك وليس متوقف</span>
|
||||
<span class="hl-key">AND NOT EXISTS</span> (
|
||||
<span class="hl-key">SELECT</span> <span class="hl-num">1</span> <span class="hl-key">FROM</span> planet_osm_line l
|
||||
<span class="hl-key">WHERE</span> <span class="hl-fn">ST_DWithin</span>(t.location::geography,
|
||||
<span class="hl-fn">ST_Transform</span>(l.way, <span class="hl-num">4326</span>)::geography, <span class="hl-num">15</span>)
|
||||
)
|
||||
),
|
||||
<span class="hl-cmt">-- الخطوة 2: تجميع النقاط القريبة باستخدام DBSCAN</span>
|
||||
clustered <span class="hl-key">AS</span> (
|
||||
<span class="hl-key">SELECT</span> *,
|
||||
<span class="hl-fn">ST_ClusterDBSCAN</span>(location::geometry,
|
||||
eps := <span class="hl-num">0.0003</span>, minpoints := <span class="hl-num">5</span>) <span class="hl-key">OVER</span> () <span class="hl-key">AS</span> cluster_id
|
||||
<span class="hl-key">FROM</span> off_road_points
|
||||
)
|
||||
<span class="hl-cmt">-- الخطوة 3: تحويل كل تجمع إلى خط طريق مرشح</span>
|
||||
<span class="hl-key">SELECT</span> cluster_id,
|
||||
<span class="hl-fn">COUNT</span>(<span class="hl-key">DISTINCT</span> "driverId") <span class="hl-key">AS</span> unique_drivers,
|
||||
<span class="hl-fn">ST_AsGeoJSON</span>(<span class="hl-fn">ST_MakeLine</span>(location <span class="hl-key">ORDER BY</span> timestamp)) <span class="hl-key">AS</span> geojson
|
||||
<span class="hl-key">FROM</span> clustered <span class="hl-key">WHERE</span> cluster_id <span class="hl-key">IS NOT NULL</span>
|
||||
<span class="hl-key">GROUP BY</span> cluster_id
|
||||
<span class="hl-key">HAVING COUNT</span>(<span class="hl-key">DISTINCT</span> "driverId") >= <span class="hl-num">2</span></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Chapter 07: API Reference -->
|
||||
<section class="chapter fade-in" id="api-reference">
|
||||
<div class="chapter-header"><div class="chapter-num">07</div><h2>مرجع الـ API — كل نقاط النهاية</h2></div>
|
||||
<p>جميع المسارات محمية بـ <code style="color:var(--teal)">ApiKeyGuard</code> عبر الترويسة <code>x-api-key</code>.</p>
|
||||
|
||||
<table class="api-table">
|
||||
<thead><tr><th>الطريقة</th><th>المسار</th><th>الوصف</th><th>الملف المصدر</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="method post">POST</span></td><td><span class="endpoint">/api/telemetry</span></td><td>استقبال بيانات تتبع السائقين كل 3 ثوانٍ</td><td style="color:var(--dim)">telemetry.controller.ts</td></tr>
|
||||
<tr><td><span class="method post">POST</span></td><td><span class="endpoint">/api/telemetry/sync</span></td><td>مزامنة يدوية من الخادم الخارجي</td><td style="color:var(--dim)">telemetry.controller.ts</td></tr>
|
||||
<tr><td><span class="method post">POST</span></td><td><span class="endpoint">/api/telemetry/process-intelligence</span></td><td>🚀 تنفيذ دورة الذكاء كاملة (مزامنة + تحليل)</td><td style="color:var(--dim)">telemetry.controller.ts</td></tr>
|
||||
<tr><td><span class="method get">GET</span></td><td><span class="endpoint">/api/maps/route?fromLat&fromLng&toLat&toLng</span></td><td>حساب أقصر مسار عبر GraphHopper</td><td style="color:var(--dim)">maps.controller.ts</td></tr>
|
||||
<tr><td><span class="method get">GET</span></td><td><span class="endpoint">/api/geocoding/search?q&lat&lng&country</span></td><td>بحث مكاني متعدد الأقاليم</td><td style="color:var(--dim)">geocoding.controller.ts</td></tr>
|
||||
<tr><td><span class="method get">GET</span></td><td><span class="endpoint">/api/geocoding/reverse?lat&lng</span></td><td>تحويل إحداثيات → عنوان</td><td style="color:var(--dim)">geocoding.controller.ts</td></tr>
|
||||
<tr><td><span class="method post">POST</span></td><td><span class="endpoint">/api/geocoding/upsert-batch</span></td><td>إدخال أماكن بالجملة (Scraper)</td><td style="color:var(--dim)">geocoding.controller.ts</td></tr>
|
||||
<tr><td><span class="method post">POST</span></td><td><span class="endpoint">/api/map-refinement/analyze-speeds</span></td><td>تحليل سرعات الطرق</td><td style="color:var(--dim)">map-refinement.controller.ts</td></tr>
|
||||
<tr><td><span class="method post">POST</span></td><td><span class="endpoint">/api/map-refinement/discover-roads</span></td><td>اكتشاف طرق جديدة (DBSCAN)</td><td style="color:var(--dim)">map-refinement.controller.ts</td></tr>
|
||||
<tr><td><span class="method get">GET</span></td><td><span class="endpoint">/api/map-refinement/congestion?north&south&east&west</span></td><td>بيانات الازدحام للخريطة الحرارية</td><td style="color:var(--dim)">map-refinement.controller.ts</td></tr>
|
||||
<tr><td><span class="method patch">PATCH</span></td><td><span class="endpoint">/api/map-refinement/candidates/:id/approve</span></td><td>الموافقة على طريق مكتشف</td><td style="color:var(--dim)">map-refinement.controller.ts</td></tr>
|
||||
<tr><td><span class="method delete">DELETE</span></td><td><span class="endpoint">/api/geocoding/places?country&name</span></td><td>حذف مكان حسب الاسم أو المعرّف</td><td style="color:var(--dim)">geocoding.controller.ts</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Chapter 08: Docker -->
|
||||
<section class="chapter fade-in" id="docker">
|
||||
<div class="chapter-header"><div class="chapter-num">08</div><h2>بيئة Docker — حاويات مترابطة</h2></div>
|
||||
<p>كل خدمة تعمل في حاوية معزولة. ملف <code style="color:var(--teal)">docker-compose.yml</code> يربطها عبر شبكة داخلية:</p>
|
||||
|
||||
<div class="card-grid">
|
||||
<div class="card"><div class="icon">🐘</div><h4>db (PostGIS)</h4><p>postgis/postgis:15-3.3 — القلب. تخزين كل البيانات المكانية مع فهارس GIST.</p></div>
|
||||
<div class="card"><div class="icon">🚀</div><h4>api (NestJS)</h4><p>node:20-alpine — العقل المدبر. يعالج الطلبات وينفذ التحليلات.</p></div>
|
||||
<div class="card"><div class="icon">🗺️</div><h4>martin (Tiles)</h4><p>maplibre/martin — يولّد Vector Tiles مباشرة من PostGIS بسرعة فائقة.</p></div>
|
||||
<div class="card"><div class="icon">🧭</div><h4>routing (GraphHopper)</h4><p>يحسب أقصر المسارات باستخدام ملف OSM محدث + بيانات الازدحام.</p></div>
|
||||
<div class="card"><div class="icon">⚡</div><h4>redis (Cache)</h4><p>redis:7-alpine — تخزين مؤقت لحالة الازدحام اللحظية.</p></div>
|
||||
<div class="card"><div class="icon">🌐</div><h4>web (React)</h4><p>لوحة تحكم الخرائط التفاعلية مع عرض البيانات.</p></div>
|
||||
</div>
|
||||
|
||||
<div class="code-card">
|
||||
<div class="code-header"><div class="file"><span class="dot" style="background:var(--blue)"></span> docker-compose.yml (مقتطف)</div><div class="lang">YAML</div></div>
|
||||
<pre class="code"><span class="hl-type">services</span>:
|
||||
<span class="hl-fn">db</span>:
|
||||
image: <span class="hl-str">postgis/postgis:15-3.3</span>
|
||||
volumes:
|
||||
- <span class="hl-str">postgres_data:/var/lib/postgresql/data</span>
|
||||
healthcheck:
|
||||
test: [<span class="hl-str">"CMD-SHELL"</span>, <span class="hl-str">"pg_isready"</span>]
|
||||
|
||||
<span class="hl-fn">api</span>:
|
||||
build: ./infrastructure/docker/api/Dockerfile
|
||||
ports: [<span class="hl-str">"3200:3200"</span>]
|
||||
environment:
|
||||
- <span class="hl-str">DATABASE_URL=postgresql://user:pass@db:5432/mapdb</span>
|
||||
- <span class="hl-str">REDIS_URL=redis://redis:6379</span>
|
||||
depends_on:
|
||||
db: { condition: <span class="hl-str">service_healthy</span> }
|
||||
redis: { condition: <span class="hl-str">service_healthy</span> }
|
||||
|
||||
<span class="hl-fn">martin</span>:
|
||||
image: <span class="hl-str">maplibre/martin:latest</span>
|
||||
ports: [<span class="hl-str">"3202:3000"</span>]
|
||||
command: <span class="hl-str">postgresql://user:pass@db:5432/mapdb</span></pre>
|
||||
</div>
|
||||
|
||||
<div class="callout info"><strong>💻 أوامر التشغيل الأساسية:</strong><br>
|
||||
<code style="color:var(--teal)">$ docker-compose up -d --build</code> — بناء وتشغيل كل الحاويات<br>
|
||||
<code style="color:var(--teal)">$ docker-compose ps</code> — عرض حالة الحاويات<br>
|
||||
<code style="color:var(--teal)">$ docker-compose logs -f api</code> — مراقبة سجلات الـ API
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Chapter 09: NGINX -->
|
||||
<section class="chapter fade-in" id="nginx">
|
||||
<div class="chapter-header"><div class="chapter-num">09</div><h2>إعدادات NGINX — البوابة الأمامية</h2></div>
|
||||
<p>NGINX يعمل كـ <strong>Reverse Proxy</strong> — يستقبل كل الطلبات على المنفذ 80 ويوجهها للحاوية المناسبة:</p>
|
||||
|
||||
<div class="code-card">
|
||||
<div class="code-header"><div class="file"><span class="dot" style="background:var(--green)"></span> nginx.conf</div><div class="lang">NGINX</div></div>
|
||||
<pre class="code"><span class="hl-key">server</span> {
|
||||
listen <span class="hl-num">80</span>;
|
||||
server_name portal.intaleq.map;
|
||||
|
||||
<span class="hl-cmt"># توجيه طلبات الـ API إلى حاوية NestJS</span>
|
||||
<span class="hl-key">location</span> /api {
|
||||
proxy_pass <span class="hl-str">http://api:3200</span>;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
<span class="hl-cmt"># توجيه طلبات بلاط الخرائط إلى Martin</span>
|
||||
<span class="hl-key">location</span> /tiles {
|
||||
proxy_pass <span class="hl-str">http://martin:3000</span>;
|
||||
}
|
||||
|
||||
<span class="hl-cmt"># توجيه طلبات التوجيه إلى GraphHopper</span>
|
||||
<span class="hl-key">location</span> /routing {
|
||||
proxy_pass <span class="hl-str">http://routing:8989</span>;
|
||||
}
|
||||
}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p>نظام "انطلق" — الدليل الهندسي التعليمي الشامل © 2026</p>
|
||||
<p style="margin-top:.5rem">تم بناء هذا الدليل من الكود المصدري الفعلي للمنصة</p>
|
||||
</footer>
|
||||
|
||||
|
||||
<script>
|
||||
const obs = new IntersectionObserver(e => e.forEach(el => {if(el.isIntersecting)el.target.classList.add('visible')}),{threshold:.1});
|
||||
document.querySelectorAll('.fade-in').forEach(r => obs.observe(r));
|
||||
|
||||
// Active nav link tracking
|
||||
const sections = document.querySelectorAll('.chapter, .hero');
|
||||
const navLinks = document.querySelectorAll('.nav-link');
|
||||
window.addEventListener('scroll', () => {
|
||||
let current = '';
|
||||
sections.forEach(s => { if(scrollY >= s.offsetTop - 200) current = s.id; });
|
||||
navLinks.forEach(l => { l.classList.toggle('active', l.getAttribute('href') === '#' + current); });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -5,7 +5,6 @@ import { ApiKeyGuard } from '../common/guards/api-key.guard';
|
||||
|
||||
@ApiTags('geocoding')
|
||||
@Controller('geocoding')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
export class GeocodingController {
|
||||
constructor(private readonly geocodingService: GeocodingService) {}
|
||||
|
||||
@@ -24,13 +23,13 @@ export class GeocodingController {
|
||||
@Query('country') country?: string,
|
||||
) {
|
||||
// NestJS @Query() always receives strings — must parse explicitly for numeric types
|
||||
const parsedLat = lat !== undefined ? parseFloat(lat) : undefined;
|
||||
const parsedLng = lng !== undefined ? parseFloat(lng) : undefined;
|
||||
const parsedLat = lat !== undefined ? parseFloat(lat) : Number.NaN;
|
||||
const parsedLng = lng !== undefined ? parseFloat(lng) : Number.NaN;
|
||||
const parsedRadius = radius !== undefined ? parseFloat(radius) : 20000;
|
||||
|
||||
// Guard against malformed float values (NaN breaks PostGIS)
|
||||
const safeLat = parsedLat !== undefined && !isNaN(parsedLat) ? parsedLat : undefined;
|
||||
const safeLng = parsedLng !== undefined && !isNaN(parsedLng) ? parsedLng : undefined;
|
||||
const safeLat = !isNaN(parsedLat) ? parsedLat : undefined;
|
||||
const safeLng = !isNaN(parsedLng) ? parsedLng : undefined;
|
||||
|
||||
return this.geocodingService.searchPlaces(query, safeLat, safeLng, parsedRadius, country);
|
||||
}
|
||||
@@ -54,12 +53,14 @@ export class GeocodingController {
|
||||
}
|
||||
|
||||
@Post('places')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@ApiOperation({ summary: 'Add a new location (User Submitted)' })
|
||||
async addPlace(@Body() placeData: any) {
|
||||
return this.geocodingService.addPlace(placeData);
|
||||
}
|
||||
|
||||
@Delete('places')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@ApiOperation({ summary: 'Delete a place by name or ID' })
|
||||
@ApiQuery({ name: 'name', required: false })
|
||||
@ApiQuery({ name: 'id', required: false, type: Number })
|
||||
@@ -79,12 +80,14 @@ export class GeocodingController {
|
||||
}
|
||||
|
||||
@Post('upsert-place')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@ApiOperation({ summary: 'Add or Update a location (Automated Scraper)' })
|
||||
async upsertPlace(@Body() placeData: any) {
|
||||
return this.geocodingService.upsertPlace(placeData);
|
||||
}
|
||||
|
||||
@Post('upsert-batch')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@ApiOperation({ summary: 'Add or Update multiple locations in bulk' })
|
||||
async upsertBatch(@Body() body: { places: any[] }) {
|
||||
return this.geocodingService.upsertBatch(body.places);
|
||||
|
||||
@@ -29,9 +29,7 @@ export class GeocodingService {
|
||||
* تحديد المستودع المناسب بناءً على الإحداثيات الجغرافية
|
||||
*/
|
||||
private getRepositoryForCoords(lat: number, lng: number): Repository<BasePlace> {
|
||||
// سوريا والأردن (توسيع الحدود لتشمل كامل دمشق وشمال سوريا)
|
||||
if (lat >= 29 && lat <= 37.5 && lng >= 34.5 && lng <= 42.5) {
|
||||
// تمييز سوريا عن الأردن (الأردن جنوب 32.5)
|
||||
if (lat > 32.5 && lng > 35.8) return (this.placesSyriaRepository as unknown) as Repository<BasePlace>;
|
||||
return (this.placesJordanRepository as unknown) as Repository<BasePlace>;
|
||||
}
|
||||
@@ -47,103 +45,70 @@ export class GeocodingService {
|
||||
return 'places_syria';
|
||||
}
|
||||
|
||||
async searchPlaces(
|
||||
query: string,
|
||||
lat?: number,
|
||||
lon?: number,
|
||||
radius: number = 20000,
|
||||
country?: string,
|
||||
) {
|
||||
async searchPlaces(query: string, lat?: number, lon?: number, radius: number = 20000, country?: string) {
|
||||
try {
|
||||
const cleanQuery = query.trim();
|
||||
const hasLocation = lat !== undefined && lon !== undefined;
|
||||
const ILikeQuery = `%${cleanQuery}%`;
|
||||
const allResults: any[] = [];
|
||||
|
||||
// تحويل المسافة بالامتار الى درجات جغرافية تقريبية للفلترة السريعة
|
||||
const radiusInDegrees = radius / 111000;
|
||||
|
||||
// 1. تحديد المنطقة (الأردن، سوريا، مصر)
|
||||
let targetRegion = country?.toLowerCase();
|
||||
|
||||
if (!targetRegion && hasLocation) {
|
||||
const repo = this.getRepositoryForCoords(lat!, lon!);
|
||||
const tableName = this.getTableNameForRepo(repo);
|
||||
targetRegion = tableName.replace('places_', '');
|
||||
}
|
||||
|
||||
const userPointSql = hasLocation ? `ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)` : 'NULL';
|
||||
|
||||
// 2. البحث في جداول المستخدم (Syria, Egypt, Jordan)
|
||||
const userTables = targetRegion
|
||||
? (['syria', 'egypt', 'jordan'].includes(targetRegion) ? [`places_${targetRegion}`] : [])
|
||||
: ['places_syria', 'places_egypt', 'places_jordan'];
|
||||
|
||||
for (const tableName of userTables) {
|
||||
let repo: Repository<any>;
|
||||
if (tableName === 'places_egypt') repo = this.placesEgyptRepository;
|
||||
else if (tableName === 'places_jordan') repo = this.placesJordanRepository;
|
||||
else repo = this.placesSyriaRepository;
|
||||
|
||||
let repo: Repository<any> = tableName === 'places_egypt' ? this.placesEgyptRepository : (tableName === 'places_jordan' ? this.placesJordanRepository : this.placesSyriaRepository);
|
||||
const userQuery = `
|
||||
SELECT id, name, name_ar, name_en, category, latitude, longitude, address, '${tableName.replace('places_', '')}' as region, 'user_submitted' as source,
|
||||
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ${userPointSql}) ELSE 0 END as distance,
|
||||
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,
|
||||
similarity(COALESCE(name_ar, ''), $1) + similarity(COALESCE(name, ''), $1) as relevance
|
||||
FROM ${tableName}
|
||||
WHERE (name_ar % $1 OR name % $1 OR name_ar ILIKE $4 OR name ILIKE $4)
|
||||
${hasLocation ? `AND (location && ST_Expand(${userPointSql}, $5::float) OR ST_DistanceSphere(location, ${userPointSql}) <= $6::float)` : ''}
|
||||
${hasLocation ? `AND (location && ST_Expand(ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326), $5::float) OR ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $6::float)` : ''}
|
||||
ORDER BY relevance DESC, distance ASC LIMIT 15
|
||||
`;
|
||||
const results = await repo.query(userQuery, [cleanQuery, lat || null, lon || null, ILikeQuery, radiusInDegrees, radius]);
|
||||
allResults.push(...results);
|
||||
}
|
||||
|
||||
// 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)';
|
||||
}
|
||||
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,
|
||||
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) + 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::float) OR ST_DistanceSphere(geom, ${userPointSql}) <= $6::float)` : ''}
|
||||
${hasLocation ? `AND (geom && ST_Expand(ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326), $5::float) OR ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $6::float)` : ''}
|
||||
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);
|
||||
})
|
||||
.sort((a, b) => hasLocation ? ((a.distance - b.distance) || (b.relevance - a.relevance)) : ((b.relevance - a.relevance) || (a.distance - b.distance)))
|
||||
.slice(0, 20)
|
||||
// 5. تطبيع البيانات: إضافة حقول location و distance_km للتوافق مع الواجهة الأمامية
|
||||
.map(r => ({
|
||||
...r,
|
||||
latitude: parseFloat(r.latitude),
|
||||
longitude: parseFloat(r.longitude),
|
||||
distance_km: r.distance ? (Number(r.distance) / 1000).toFixed(2) : null,
|
||||
location: {
|
||||
lat: parseFloat(r.latitude),
|
||||
lng: parseFloat(r.longitude),
|
||||
},
|
||||
location: { lat: parseFloat(r.latitude), lng: parseFloat(r.longitude) },
|
||||
}));
|
||||
|
||||
this.logger.debug(`Spatial Search complete (Sorted by ${hasLocation ? 'Distance' : 'Relevance'}). Results: ${sortedResults.length}`);
|
||||
return { results: sortedResults };
|
||||
} catch (e) {
|
||||
this.logger.error('Optimized Spatial Search failed:', e);
|
||||
@@ -157,9 +122,9 @@ export class GeocodingService {
|
||||
const tableName = this.getTableNameForRepo(repo);
|
||||
const query = `
|
||||
SELECT id, name, name_ar, category, latitude, longitude, address, 'user_place' as source,
|
||||
ST_DistanceSphere(location::geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326)) as distance
|
||||
ST_DistanceSphere(location::geometry, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
|
||||
FROM ${tableName} WHERE location IS NOT NULL
|
||||
ORDER BY location::geometry <-> ST_SetSRID(ST_MakePoint($1, $2), 4326) ASC LIMIT 3
|
||||
ORDER BY location::geometry <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 3
|
||||
`;
|
||||
return await repo.query(query, [lng, lat]);
|
||||
} catch (error) {
|
||||
@@ -170,165 +135,60 @@ export class GeocodingService {
|
||||
|
||||
async addPlace(data: Partial<BasePlace>) {
|
||||
try {
|
||||
const lat = Number(data.latitude);
|
||||
const lng = Number(data.longitude);
|
||||
const repo = this.getRepositoryForCoords(lat, lng);
|
||||
const tableName = this.getTableNameForRepo(repo);
|
||||
|
||||
const newPlace = repo.create({
|
||||
...data,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
created_at: new Date(),
|
||||
location: { type: 'Point', coordinates: [lng, lat] }
|
||||
});
|
||||
const lat = Number(data.latitude), lng = Number(data.longitude);
|
||||
const repo = this.getRepositoryForCoords(lat, lng), tableName = this.getTableNameForRepo(repo);
|
||||
const newPlace = repo.create({ ...data, latitude: lat, longitude: lng, created_at: new Date(), location: { type: 'Point', coordinates: [lng, lat] } });
|
||||
const savedPlace = await repo.save(newPlace);
|
||||
|
||||
// تأكيد SRID في قاعدة البيانات
|
||||
await repo.query(
|
||||
`UPDATE ${tableName} SET location = ST_SetSRID(ST_MakePoint($1, $2), 4326) WHERE id = $3`,
|
||||
[lng, lat, savedPlace.id],
|
||||
);
|
||||
|
||||
await repo.query(`UPDATE ${tableName} SET location = ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) WHERE id = $3`, [lng, lat, savedPlace.id]);
|
||||
return { ...savedPlace, latitude: lat, longitude: lng, location: `POINT(${lng} ${lat})` };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to add place:', error.message);
|
||||
throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
} catch (error) { throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); }
|
||||
}
|
||||
|
||||
async upsertPlace(data: Partial<BasePlace>) {
|
||||
try {
|
||||
const lat = Number(data.latitude);
|
||||
const lng = Number(data.longitude);
|
||||
const repo = this.getRepositoryForCoords(lat, lng);
|
||||
const tableName = this.getTableNameForRepo(repo);
|
||||
|
||||
const existing = await repo.query(
|
||||
`SELECT id, name, name_ar FROM ${tableName} WHERE ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1, $2), 4326)) < 15 LIMIT 1`,
|
||||
[lng, lat],
|
||||
);
|
||||
|
||||
const lat = Number(data.latitude), lng = Number(data.longitude);
|
||||
const repo = this.getRepositoryForCoords(lat, lng), tableName = this.getTableNameForRepo(repo);
|
||||
const existing = await repo.query(`SELECT id, name FROM ${tableName} WHERE ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) < 15 LIMIT 1`, [lng, lat]);
|
||||
if (existing && existing.length > 0) {
|
||||
const id = existing[0].id;
|
||||
const currentName = existing[0].name_ar || existing[0].name || '';
|
||||
const newName = data.name_ar || data.name || '';
|
||||
|
||||
// منطق "الاسم الأفضل": تحديث الاسم فقط إذا كان الجديد أطول أو يحتوي على تفاصيل أكثر
|
||||
const shouldUpdateName = newName.length > currentName.length || (newName.includes('منزل') && !currentName.includes('منزل'));
|
||||
|
||||
await repo.update(id, {
|
||||
name: shouldUpdateName ? (data.name || undefined) : undefined,
|
||||
name_ar: shouldUpdateName ? (data.name_ar || undefined) : undefined,
|
||||
category: data.category || undefined,
|
||||
city: data.city || undefined,
|
||||
});
|
||||
|
||||
return { id, action: 'updated' };
|
||||
await repo.update(existing[0].id, { name: data.name || undefined, name_ar: data.name_ar || undefined, category: data.category || undefined });
|
||||
return { id: existing[0].id, action: 'updated' };
|
||||
}
|
||||
|
||||
const result = await this.addPlace(data);
|
||||
return { id: result.id, action: 'created' };
|
||||
} catch (error) {
|
||||
this.logger.error('Upsert failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
} catch (error) { throw error; }
|
||||
}
|
||||
|
||||
async upsertBatch(places: Partial<BasePlace>[]) {
|
||||
const results: { id: number; action: string }[] = [];
|
||||
for (const place of places) {
|
||||
try {
|
||||
// الاستراتيجية الجديدة: حفظ كل ما يصل فوراً لضمان السرعة القصوى (Raw Ingestion)
|
||||
const result = await this.addPlace(place);
|
||||
results.push({ id: result.id, action: 'created' });
|
||||
} catch (e) {
|
||||
this.logger.error(`Batch item failed: ${place.name || place.name_ar}`, e.message);
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: places.length,
|
||||
processed: results.length,
|
||||
created: results.length,
|
||||
updated: 0
|
||||
};
|
||||
const results: any[] = [];
|
||||
for (const place of places) { try { const r = await this.addPlace(place); results.push({ id: r.id, action: 'created' }); } catch (e) {} }
|
||||
return { total: places.length, processed: results.length, created: results.length, updated: 0 };
|
||||
}
|
||||
|
||||
async getRecentPlaces(limit: number = 50) {
|
||||
const syria = await this.placesSyriaRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
||||
const jordan = await this.placesJordanRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
||||
const egypt = await this.placesEgyptRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
||||
|
||||
return [...syria, ...jordan, ...egypt]
|
||||
.sort((a, b) => b.created_at.getTime() - a.created_at.getTime())
|
||||
.slice(0, limit);
|
||||
const s = await this.placesSyriaRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
||||
const j = await this.placesJordanRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
||||
const e = await this.placesEgyptRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
||||
return [...s, ...j, ...e].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: [] };
|
||||
}
|
||||
const q = `SELECT id, name_ar as name, category, latitude, longitude, address, 'syria' as region FROM places_syria UNION ALL SELECT id, name_ar as name, category, latitude, longitude, address, 'jordan' as region FROM places_jordan UNION ALL SELECT id, name_ar as name, category, latitude, longitude, address, 'egypt' as region FROM places_egypt`;
|
||||
const res = await this.placesSyriaRepository.query(q);
|
||||
const features = res.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 }}));
|
||||
return { type: 'FeatureCollection', features };
|
||||
} catch (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);
|
||||
}
|
||||
const repo = this.getRepoByCountry(country);
|
||||
const r1 = await repo.delete({ name_ar: name }), r2 = await repo.delete({ name_en: name }), r3 = await repo.delete({ name: name });
|
||||
return { success: true, affected: (r1.affected || 0) + (r2.affected || 0) + (r3.affected || 0) };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
const repo = this.getRepoByCountry(country);
|
||||
return { success: true, affected: (await repo.delete(id)).affected };
|
||||
}
|
||||
|
||||
private getRepoByCountry(country: string) {
|
||||
|
||||
+1249
-183
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user