2026-04-13-4
This commit is contained in:
@@ -25,7 +25,6 @@
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^6.1.1",
|
||||
"@nestjs/serve-static": "^5.0.1",
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"axios": "^1.13.6",
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
// Intaleq Intelligence Dashboard Control
|
||||
// المتحكم البرمجي في لوحة ذكاء الخرائط
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchSummary();
|
||||
fetchCandidates();
|
||||
fetchClosures();
|
||||
|
||||
// Event Listeners
|
||||
document.getElementById('run-analysis-btn').addEventListener('click', runAnalysis);
|
||||
document.getElementById('discover-closures-btn').addEventListener('click', discoverClosures);
|
||||
});
|
||||
|
||||
// --- API FETCHERS ---
|
||||
|
||||
async function fetchSummary() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/map-refinement/summary`);
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById('stat-telemetry').innerText = Number(data.telemetry.total).toLocaleString();
|
||||
document.getElementById('stat-roads').innerText = Number(data.roads.analyzed).toLocaleString();
|
||||
document.getElementById('stat-pending').innerText = Number(data.candidates.pending).toLocaleString();
|
||||
document.getElementById('stat-closed').innerText = Number(data.roads.closed || 0).toLocaleString();
|
||||
} catch (e) {
|
||||
showToast('Failed to fetch summary');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCandidates() {
|
||||
const tableBody = document.querySelector('#candidates-table tbody');
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/map-refinement/candidates?status=pending`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
tableBody.innerHTML = '<tr class="empty-state"><td colspan="6">No pending discoveries 🏜️</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tableBody.innerHTML = data.map(c => `
|
||||
<tr>
|
||||
<td title="${c.id}">${c.id.substring(0,8)}...</td>
|
||||
<td><i class="fas fa-user-tag"></i> ${c.uniqueDriverCount}</td>
|
||||
<td>${c.totalPoints}</td>
|
||||
<td>${Math.round(c.lengthMeters)}m</td>
|
||||
<td><span class="badge-conf ${getConfClass(c.confidence)}">${Math.round(c.confidence * 100)}%</span></td>
|
||||
<td>
|
||||
<button class="btn btn-sm-success" onclick="approveCandidate('${c.id}')"><i class="fas fa-check"></i></button>
|
||||
<button class="btn btn-sm-danger" onclick="rejectCandidate('${c.id}')"><i class="fas fa-times"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
tableBody.innerHTML = '<tr class="error-state"><td colspan="6">Error loading candidates</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchClosures() {
|
||||
const tableBody = document.querySelector('#closures-table tbody');
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/map-refinement/closures`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
tableBody.innerHTML = '<tr class="empty-state"><td colspan="3">No segments flagged</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tableBody.innerHTML = data.map(s => `
|
||||
<tr>
|
||||
<td>${s.segmentId}</td>
|
||||
<td>${s.sampleCount} pts</td>
|
||||
<td><span class="badge-conf low">CLOSED</span></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
tableBody.innerHTML = '<tr><td colspan="3">Error loading closures</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// --- ACTIONS ---
|
||||
|
||||
async function approveCandidate(id) {
|
||||
if (!confirm('Add this path to the approved map queue?')) return;
|
||||
try {
|
||||
await fetch(`${API_BASE}/map-refinement/candidates/${id}/approve`, { method: 'PATCH' });
|
||||
showToast('Road Approved ✅');
|
||||
fetchCandidates();
|
||||
fetchSummary();
|
||||
} catch (e) {
|
||||
showToast('Approval failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectCandidate(id) {
|
||||
try {
|
||||
await fetch(`${API_BASE}/map-refinement/candidates/${id}/reject`, { method: 'PATCH' });
|
||||
showToast('Discovery Rejected ❌');
|
||||
fetchCandidates();
|
||||
fetchSummary();
|
||||
} catch (e) {
|
||||
showToast('Rejection failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function runAnalysis() {
|
||||
const btn = document.getElementById('run-analysis-btn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Analyzing...';
|
||||
|
||||
try {
|
||||
await fetch(`${API_BASE}/telemetry/process-intelligence?days=2`, { method: 'POST' });
|
||||
showToast('Deep Intelligence started 🧠. Check Telegram for report soon!');
|
||||
} catch (e) {
|
||||
showToast('Analysis trigger failed');
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-play"></i> Run Intelligence';
|
||||
fetchSummary();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverClosures() {
|
||||
showToast('Scanning for closures... 🚧');
|
||||
try {
|
||||
await fetch(`${API_BASE}/map-refinement/discover-closures`, { method: 'POST' });
|
||||
showToast('Closure scan complete');
|
||||
fetchClosures();
|
||||
fetchSummary();
|
||||
} catch (e) {
|
||||
showToast('Closure discovery failed');
|
||||
}
|
||||
}
|
||||
|
||||
// --- HELPERS ---
|
||||
|
||||
function getConfClass(conf) {
|
||||
if (conf > 0.8) return 'high';
|
||||
if (conf > 0.5) return 'med';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function showToast(msg) {
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.innerText = msg;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 4000);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Intaleq | Map Intelligence Dashboard</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<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=Inter:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
</head>
|
||||
<body class="dark-theme">
|
||||
<div class="app-container">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="logo-container">
|
||||
<div class="logo-icon"><i class="fas fa-brain-circuit"></i></div>
|
||||
<div class="logo-text">Intaleq<span>Map AI</span></div>
|
||||
</div>
|
||||
<nav class="main-nav">
|
||||
<a href="#" class="nav-item active"><i class="fas fa-chart-line"></i> Summary</a>
|
||||
<a href="#" class="nav-item"><i class="fas fa-road"></i> Candidates</a>
|
||||
<a href="#" class="nav-item"><i class="fas fa-barrier-solid"></i> Closures</a>
|
||||
<a href="/api/docs" target="_blank" class="nav-item"><i class="fas fa-book"></i> API Docs</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="status-indicator">
|
||||
<span class="dot pulse"></span>
|
||||
<span>Live Traffic Engine</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<header class="top-bar">
|
||||
<div class="search-container">
|
||||
<i class="fas fa-search"></i>
|
||||
<input type="text" placeholder="Search segments or drivers...">
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button id="run-analysis-btn" class="btn btn-primary">
|
||||
<i class="fas fa-play"></i> Run Intelligence
|
||||
</button>
|
||||
<div class="user-profile">
|
||||
<img src="https://ui-avatars.com/api/?name=Admin&background=6366f1&color=fff" alt="Admin">
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="dashboard-scroll">
|
||||
<!-- Stats Overview -->
|
||||
<section class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon purple"><i class="fas fa-satellite-dish"></i></div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-label">Telemetry Points</span>
|
||||
<h2 id="stat-telemetry">0</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon blue"><i class="fas fa-route"></i></div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-label">Analyzed Segments</span>
|
||||
<h2 id="stat-roads">0</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon orange"><i class="fas fa-sparkles"></i></div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-label">Pending Roads</span>
|
||||
<h2 id="stat-pending">0</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon red"><i class="fas fa-road-barrier"></i></div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-label">Active Closures</span>
|
||||
<h2 id="stat-closed">0</h2>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="content-grid">
|
||||
<!-- Road Candidates Table -->
|
||||
<section class="card candidate-section">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-map-location-dot"></i> Discovered Road Candidates</h3>
|
||||
<button class="btn btn-ghost" onclick="fetchCandidates()">Refresh</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table id="candidates-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Drivers</th>
|
||||
<th>Points</th>
|
||||
<th>L (m)</th>
|
||||
<th>Confidence</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="empty-state">
|
||||
<td colspan="6">Loading discoveries...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Road Closures -->
|
||||
<section class="card closure-section">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-road-lock"></i> Road Closures</h3>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-ghost" onclick="fetchClosures()">Refresh</button>
|
||||
<button id="discover-closures-btn" class="btn btn-secondary-sm">Check Now</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table id="closures-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Segment ID</th>
|
||||
<th>Historical Vol</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="empty-state">
|
||||
<td colspan="3">No closures detected</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Notification Container -->
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,365 +0,0 @@
|
||||
:root {
|
||||
--bg-dark: #0f172a;
|
||||
--bg-navbar: #1e293b;
|
||||
--card-bg: rgba(30, 41, 59, 0.7);
|
||||
--border-color: #334155;
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
--accent-purple: #8b5cf6;
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-orange: #f59e0b;
|
||||
--accent-red: #ef4444;
|
||||
--accent-green: #10b981;
|
||||
--sidebar-width: 260px;
|
||||
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Sidebar Styling */
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background-color: var(--bg-navbar);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, var(--accent-purple), var(--accent-blue));
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 1.25rem;
|
||||
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.logo-text span {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.nav-item i {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-item:hover, .nav-item.active {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background-color: rgba(139, 92, 246, 0.1);
|
||||
color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--accent-green);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); }
|
||||
70% { box-shadow: 0 0 0 8px rgba(16, 185, 129, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
|
||||
}
|
||||
|
||||
/* Main Content Area */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-dark);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
height: 70px;
|
||||
padding: 0 2rem;
|
||||
background-color: rgba(15, 23, 42, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
position: relative;
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.search-container i {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.search-container input {
|
||||
width: 100%;
|
||||
padding: 0.6rem 1rem 0.6rem 2.8rem;
|
||||
background-color: var(--bg-navbar);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.user-profile img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Dashboard Content */
|
||||
.dashboard-scroll {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-icon.purple { background: rgba(139, 92, 246, 0.15); color: var(--accent-purple); }
|
||||
.stat-icon.blue { background: rgba(59, 130, 246, 0.15); color: var(--accent-blue); }
|
||||
.stat-icon.orange { background: rgba(245, 158, 11, 0.15); color: var(--accent-orange); }
|
||||
.stat-icon.red { background: rgba(239, 68, 68, 0.15); color: var(--accent-red); }
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-info h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Content Sections */
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Table Styling */
|
||||
.table-container {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
.empty-state { text-align: center; color: var(--text-secondary); padding: 3rem; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.btn-primary { background-color: var(--accent-purple); color: white; }
|
||||
.btn-primary:hover { background-color: #7c3aed; filter: brightness(1.1); }
|
||||
|
||||
.btn-ghost { background: transparent; color: var(--text-secondary); border: 1px solid var(--border-color); }
|
||||
.btn-ghost:hover { border-color: var(--text-primary); color: white; }
|
||||
|
||||
.btn-secondary-sm { padding: 0.4rem 0.8rem; font-size: 0.8rem; background: var(--bg-navbar); color: var(--accent-blue); border: 1px solid var(--accent-blue); }
|
||||
|
||||
.btn-sm-success { background: rgba(16, 185, 129, 0.1); color: var(--accent-green); padding: 0.3rem 0.7rem; font-size: 0.75rem; }
|
||||
.btn-sm-danger { background: rgba(239, 68, 68, 0.1); color: var(--accent-red); padding: 0.3rem 0.7rem; font-size: 0.75rem; }
|
||||
|
||||
/* Confidence Badge */
|
||||
.badge-conf {
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.high { background: rgba(16, 185, 129, 0.2); color: var(--accent-green); }
|
||||
.med { background: rgba(245, 158, 11, 0.2); color: var(--accent-orange); }
|
||||
.low { background: rgba(239, 68, 68, 0.2); color: var(--accent-red); }
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 10px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }
|
||||
|
||||
/* Toasts */
|
||||
#toast-container { position: fixed; bottom: 2rem; right: 2rem; display: flex; flex-direction: column; gap: 0.5rem; z-index: 1000; }
|
||||
.toast { background: var(--bg-navbar); color: white; padding: 1rem 1.5rem; border-radius: 10px; border-left: 4px solid var(--accent-purple); box-shadow: 0 10px 15px rgba(0,0,0,0.5); animation: slideIn 0.3s ease-out; }
|
||||
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
@@ -2,8 +2,6 @@ import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { join } from 'path';
|
||||
import { TelemetryModule } from './telemetry/telemetry.module';
|
||||
import { MapsModule } from './maps/maps.module';
|
||||
import { GeocodingModule } from './geocoding/geocoding.module';
|
||||
@@ -14,10 +12,6 @@ import { GeocodingModule } from './geocoding/geocoding.module';
|
||||
isGlobal: true,
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join(__dirname, '..', 'public', 'intel'),
|
||||
serveRoot: '/api/intel',
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
|
||||
@@ -13,7 +13,7 @@ export class ApiKeyGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const apiKeyHeader = request.headers['x-api-key'];
|
||||
const validApiKey = this.configService.get<string>('MAP_API_KEY') || 'intaleq_secret_2026';
|
||||
const validApiKey = this.configService.get<string>('MAP_API_KEY') || 'intaleq_premium_saas_2026_secure_key_x';
|
||||
|
||||
// Enforce API key match
|
||||
if (apiKeyHeader !== validApiKey) {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AdminBoundary } from './entities/admin-boundary.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AdminBoundariesService {
|
||||
private readonly logger = new Logger(AdminBoundariesService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AdminBoundary)
|
||||
private adminBoundaryRepo: Repository<AdminBoundary>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Import GeoJSON features into the admin_boundaries table.
|
||||
* Expects feature.properties to contain 'admin_level', 'name:ar'/'name', 'name:en'
|
||||
*/
|
||||
async importGeoJSON(
|
||||
targetCountryCode: string,
|
||||
geoJson: any
|
||||
): Promise<{ success: boolean; imported: number; errors: number; skipped: number }> {
|
||||
this.logger.log(`Importing admin boundaries for ${targetCountryCode}...`);
|
||||
|
||||
let imported = 0;
|
||||
let errors = 0;
|
||||
let skipped = 0;
|
||||
|
||||
if (!geoJson || !geoJson.features || !Array.isArray(geoJson.features)) {
|
||||
this.logger.error('Invalid GeoJSON format');
|
||||
return { success: false, imported: 0, errors: 1, skipped: 0 };
|
||||
}
|
||||
|
||||
for (const feature of geoJson.features) {
|
||||
try {
|
||||
const props = feature.properties || {};
|
||||
const countryCode = props.country || props.iso_3166_1_alpha2;
|
||||
|
||||
// 1. Filter by country code to avoid importing overlapping data (e.g. Hebrew names in Jordan)
|
||||
if (targetCountryCode && countryCode && countryCode.toUpperCase() !== targetCountryCode.toUpperCase()) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Map Overture Subtype to our Admin Level
|
||||
// Overture: country, region, county, localadmin, locality, neighborhood
|
||||
const subtype = (props.subtype || '').toLowerCase();
|
||||
let adminLevel: number;
|
||||
|
||||
switch (subtype) {
|
||||
case 'country': adminLevel = 2; break;
|
||||
case 'region': adminLevel = 4; break; // Governorate (Muhafazah)
|
||||
case 'county': adminLevel = 6; break; // District (Liwa)
|
||||
case 'localadmin': adminLevel = 8; break; // Sub-district (Qada)
|
||||
case 'locality': adminLevel = 8; break; // City/Town
|
||||
case 'neighborhood': adminLevel = 10; break;
|
||||
case 'macrohood': adminLevel = 9; break; // Intermediate
|
||||
case 'microhood': adminLevel = 11; break; // Sub-neighborhood
|
||||
default:
|
||||
adminLevel = parseInt(props.admin_level || props.adminLevel, 10);
|
||||
}
|
||||
|
||||
if (isNaN(adminLevel)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Name Handling (Prioritize Arabic)
|
||||
const nameAr = props['names']?.['primary'] || props['names']?.['common'] || props['name_ar'] || props['name'];
|
||||
const nameEn = props['names']?.['en'] || props['name_en'];
|
||||
|
||||
// Simple check to skip obviously non-Arabic primary names if the target is an Arabic country
|
||||
const isArabic = (text: string) => /[\u0600-\u06FF]/.test(text);
|
||||
if (['JO', 'SY', 'EG'].includes(targetCountryCode.toUpperCase()) && nameAr && !isArabic(nameAr)) {
|
||||
// If primary name isn't Arabic, try to find an Arabic variant in names map
|
||||
const alternativeAr = Object.values(props['names'] || {}).find(v => typeof v === 'string' && isArabic(v));
|
||||
if (!alternativeAr) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let geom = feature.geometry;
|
||||
if (geom.type === 'Polygon') {
|
||||
geom = { type: 'MultiPolygon', coordinates: [geom.coordinates] };
|
||||
} else if (geom.type !== 'MultiPolygon') {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const boundary = this.adminBoundaryRepo.create({
|
||||
country_code: targetCountryCode.toUpperCase(),
|
||||
admin_level: adminLevel,
|
||||
name_ar: nameAr,
|
||||
name_en: nameEn,
|
||||
geom: geom,
|
||||
});
|
||||
|
||||
await this.adminBoundaryRepo.save(boundary);
|
||||
imported++;
|
||||
} catch (err) {
|
||||
this.logger.error(`Error importing feature: ${err.message}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Import finished. Imported: ${imported}, Errors: ${errors}, Skipped: ${skipped}`);
|
||||
return { success: true, imported, errors, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import GeoJSON from a local file on the server.
|
||||
*/
|
||||
async importFromFile(countryCode: string, filePath: string): Promise<{ success: boolean; imported: number; errors: number; skipped: number }> {
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(filePath)) {
|
||||
this.logger.error(`File not found: ${filePath}`);
|
||||
return { success: false, imported: 0, errors: 1, skipped: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(filePath, 'utf8');
|
||||
const geoJson = JSON.parse(data);
|
||||
return this.importGeoJSON(countryCode, geoJson);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to read or parse GeoJSON file: ${err.message}`);
|
||||
return { success: false, imported: 0, errors: 1, skipped: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
|
||||
|
||||
@Entity('admin_boundaries')
|
||||
export class AdminBoundary {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ length: 3 })
|
||||
@Index()
|
||||
country_code: string;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
@Index()
|
||||
admin_level: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Index()
|
||||
name_ar: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
name_en: string;
|
||||
|
||||
@Column({ type: 'geometry', spatialFeatureType: 'MultiPolygon', srid: 4326, nullable: true })
|
||||
@Index({ spatial: true })
|
||||
geom: any;
|
||||
}
|
||||
@@ -45,4 +45,20 @@ export abstract class BasePlace {
|
||||
@Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326, nullable: true })
|
||||
@Index({ spatial: true })
|
||||
location: any;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
@Index()
|
||||
admin_level4_id: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
@Index()
|
||||
admin_level6_id: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
@Index()
|
||||
admin_level8_id: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
@Index()
|
||||
admin_level10_id: number;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@ export class GeocodingInitService implements OnModuleInit {
|
||||
BEGIN
|
||||
IF NEW.latitude IS NOT NULL AND NEW.longitude IS NOT NULL THEN
|
||||
NEW.location := ST_SetSRID(ST_MakePoint(CAST(NEW.longitude AS FLOAT), CAST(NEW.latitude AS FLOAT)), 4326);
|
||||
|
||||
NEW.admin_level4_id := (SELECT id FROM admin_boundaries WHERE admin_level = 4 AND ST_Contains(geom, NEW.location) LIMIT 1);
|
||||
NEW.admin_level6_id := (SELECT id FROM admin_boundaries WHERE admin_level = 6 AND ST_Contains(geom, NEW.location) LIMIT 1);
|
||||
NEW.admin_level8_id := (SELECT id FROM admin_boundaries WHERE admin_level = 8 AND ST_Contains(geom, NEW.location) LIMIT 1);
|
||||
NEW.admin_level10_id := (SELECT id FROM admin_boundaries WHERE admin_level = 10 AND ST_Contains(geom, NEW.location) LIMIT 1);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Controller, Get, Post, Delete, Body, Query, UseGuards, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import { GeocodingService } from './geocoding.service';
|
||||
import { AdminBoundariesService } from './admin-boundaries.service';
|
||||
import { ApiKeyGuard } from '../common/guards/api-key.guard';
|
||||
|
||||
@ApiTags('geocoding')
|
||||
@Controller('geocoding')
|
||||
export class GeocodingController {
|
||||
constructor(private readonly geocodingService: GeocodingService) {}
|
||||
constructor(
|
||||
private readonly geocodingService: GeocodingService,
|
||||
private readonly adminBoundariesService: AdminBoundariesService,
|
||||
) {}
|
||||
|
||||
@Get('search')
|
||||
@ApiOperation({ summary: 'Search for locations (Forward Geocoding)' })
|
||||
@@ -104,4 +108,16 @@ export class GeocodingController {
|
||||
async getGeoJSON() {
|
||||
return this.geocodingService.getAllPlacesGeoJSON();
|
||||
}
|
||||
|
||||
@Post('import-boundaries')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@ApiOperation({ summary: 'Import administrative boundaries from a local GeoJSON file on the server' })
|
||||
@ApiQuery({ name: 'country', required: true })
|
||||
@ApiQuery({ name: 'filePath', required: true })
|
||||
async importBoundaries(
|
||||
@Query('country') country: string,
|
||||
@Query('filePath') filePath: string,
|
||||
) {
|
||||
return this.adminBoundariesService.importFromFile(country, filePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import { PlaceJordan } from './entities/place-jordan.entity';
|
||||
import { PlaceEgypt } from './entities/place-egypt.entity';
|
||||
import { OsmArea } from './entities/osm-area.entity';
|
||||
import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
|
||||
|
||||
import { AdminBoundary } from './entities/admin-boundary.entity';
|
||||
import { AdminBoundariesService } from './admin-boundaries.service';
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
@@ -16,10 +17,11 @@ import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
|
||||
PlaceJordan,
|
||||
PlaceEgypt,
|
||||
OsmArea,
|
||||
OsmPointWithArea
|
||||
OsmPointWithArea,
|
||||
AdminBoundary
|
||||
]),
|
||||
],
|
||||
controllers: [GeocodingController],
|
||||
providers: [GeocodingService, GeocodingInitService],
|
||||
providers: [GeocodingService, GeocodingInitService, AdminBoundariesService],
|
||||
})
|
||||
export class GeocodingModule {}
|
||||
|
||||
@@ -67,12 +67,21 @@ export class GeocodingService {
|
||||
for (const tableName of userTables) {
|
||||
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, neighbourhood, latitude, longitude, address, '${tableName.replace('places_', '')}' as region, 'user_submitted' 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,
|
||||
similarity(COALESCE(name_ar, ''), $1) + similarity(COALESCE(name, ''), $1) + similarity(COALESCE(neighbourhood, ''), $1) as relevance
|
||||
FROM ${tableName}
|
||||
WHERE (name_ar % $1 OR name % $1 OR neighbourhood % $1 OR name_ar ILIKE $4 OR name ILIKE $4 OR neighbourhood ILIKE $4)
|
||||
${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)` : ''}
|
||||
SELECT
|
||||
p.id, p.name, p.name_ar, p.name_en, p.category,
|
||||
p.neighbourhood as original_neighbourhood,
|
||||
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, p.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,
|
||||
similarity(COALESCE(p.name_ar, ''), $1) + similarity(COALESCE(p.name, ''), $1) + similarity(COALESCE(p.neighbourhood, ''), $1) as relevance
|
||||
FROM ${tableName} p
|
||||
LEFT JOIN admin_boundaries n ON p.admin_level10_id = n.id
|
||||
LEFT JOIN admin_boundaries d ON p.admin_level8_id = d.id
|
||||
LEFT JOIN admin_boundaries g ON p.admin_level4_id = g.id
|
||||
WHERE (p.name_ar % $1 OR p.name % $1 OR p.neighbourhood % $1 OR p.name_ar ILIKE $4 OR p.name ILIKE $4 OR p.neighbourhood ILIKE $4)
|
||||
${hasLocation ? `AND (p.location && ST_Expand(ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326), $5::float) OR ST_DistanceSphere(p.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]);
|
||||
@@ -98,9 +107,48 @@ export class GeocodingService {
|
||||
const osmResults = await this.osmPointsRepository.query(osmQuery, [cleanQuery, lat || null, lon || null, ILikeQuery, radiusInDegrees, radius]);
|
||||
allResults.push(...osmResults);
|
||||
|
||||
// --- Consolidation: Overture Maps Integration (Now in primary DB) ---
|
||||
const overtureQuery = `
|
||||
(SELECT id::text,
|
||||
COALESCE(names->>'primary', names->>'common', 'Building') as name,
|
||||
COALESCE(names->>'primary', names->>'common', '') as name_ar,
|
||||
NULL as name_en,
|
||||
'building' as category,
|
||||
ST_Y(ST_Centroid(location)) as latitude,
|
||||
ST_X(ST_Centroid(location)) as longitude,
|
||||
'' as address,
|
||||
'overture_buildings' 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,
|
||||
0.6 as relevance
|
||||
FROM overture_building
|
||||
WHERE (names->>'primary' ILIKE $4 OR names->>'common' ILIKE $4)
|
||||
LIMIT 15)
|
||||
UNION ALL
|
||||
(SELECT id::text,
|
||||
COALESCE(names->>'primary', names->>'common', 'Street') as name,
|
||||
COALESCE(names->>'primary', names->>'common', '') as name_ar,
|
||||
NULL as name_en,
|
||||
'transportation' as category,
|
||||
ST_Y(ST_Centroid(location)) as latitude,
|
||||
ST_X(ST_Centroid(location)) as longitude,
|
||||
'' as address,
|
||||
'overture_streets' 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,
|
||||
0.6 as relevance
|
||||
FROM overture_segment
|
||||
WHERE (names->>'primary' ILIKE $4 OR names->>'common' ILIKE $4)
|
||||
LIMIT 15)
|
||||
`;
|
||||
try {
|
||||
const overtureResults = await this.osmPointsRepository.query(overtureQuery, [cleanQuery, lat || null, lon || null, ILikeQuery]);
|
||||
allResults.push(...overtureResults);
|
||||
} catch (err) {
|
||||
this.logger.warn('Overture tables search failed, logging error.', err);
|
||||
}
|
||||
|
||||
const sortedResults = allResults
|
||||
.sort((a, b) => hasLocation ? ((a.distance - b.distance) || (b.relevance - a.relevance)) : ((b.relevance - a.relevance) || (a.distance - b.distance)))
|
||||
.slice(0, 20)
|
||||
.slice(0, 25)
|
||||
.map(r => ({
|
||||
...r,
|
||||
latitude: parseFloat(r.latitude),
|
||||
@@ -111,7 +159,7 @@ export class GeocodingService {
|
||||
|
||||
return { results: sortedResults };
|
||||
} catch (e) {
|
||||
this.logger.error('Optimized Spatial Search failed:', e);
|
||||
this.logger.error('Search failed:', e);
|
||||
return { results: [] };
|
||||
}
|
||||
}
|
||||
@@ -121,10 +169,19 @@ export class GeocodingService {
|
||||
const repo = this.getRepositoryForCoords(lat, lng);
|
||||
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::float, $2::float), 4326)) as distance
|
||||
FROM ${tableName} WHERE location IS NOT NULL
|
||||
ORDER BY location::geometry <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 3
|
||||
SELECT
|
||||
p.id, p.name, p.name_ar, p.category,
|
||||
n.name_ar as neighbourhood,
|
||||
d.name_ar as district,
|
||||
g.name_ar as governorate,
|
||||
p.latitude, p.longitude, p.address, 'user_place' as source,
|
||||
ST_DistanceSphere(p.location::geometry, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
|
||||
FROM ${tableName} p
|
||||
LEFT JOIN admin_boundaries n ON p.admin_level10_id = n.id
|
||||
LEFT JOIN admin_boundaries d ON p.admin_level8_id = d.id
|
||||
LEFT JOIN admin_boundaries g ON p.admin_level4_id = g.id
|
||||
WHERE p.location IS NOT NULL
|
||||
ORDER BY p.location::geometry <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 3
|
||||
`;
|
||||
return await repo.query(query, [lng, lat]);
|
||||
} catch (error) {
|
||||
@@ -173,11 +230,23 @@ export class GeocodingService {
|
||||
|
||||
async getAllPlacesGeoJSON() {
|
||||
try {
|
||||
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 q = `
|
||||
SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_syria
|
||||
UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_jordan
|
||||
UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_egypt
|
||||
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Building') as name, 'building' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_building WHERE names->>'primary' IS NOT NULL LIMIT 500
|
||||
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Street') as name, 'street' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_segment WHERE names->>'primary' IS NOT NULL LIMIT 500
|
||||
`;
|
||||
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 }}));
|
||||
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: [] }; }
|
||||
} catch (e) {
|
||||
return { type: 'FeatureCollection', features: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async deletePlacesByName(name: string, country: string) {
|
||||
|
||||
@@ -281,6 +281,21 @@
|
||||
<!-- STATUS -->
|
||||
<div id="status"></div>
|
||||
|
||||
<!-- OPTIONS -->
|
||||
<div style="margin-top: 24px; border-top: 1px solid rgba(0,0,0,0.06); padding-top: 20px;">
|
||||
<div style="font-size: 14px; font-weight: 800; margin-bottom: 15px; color: #18100A;">🛡️ خيارات / Options</div>
|
||||
|
||||
<label style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px; cursor: pointer; font-size: 13px;">
|
||||
<input type="checkbox" id="toggle-3d" checked onchange="updateLayers()" style="accent-color: var(--primary); width: 16px; height: 16px;">
|
||||
<span>مباني ثلاثية الأبعاد / 3D Buildings</span>
|
||||
</label>
|
||||
|
||||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 13px;">
|
||||
<input type="checkbox" id="toggle-pois" checked onchange="updateLayers()" style="accent-color: var(--primary); width: 16px; height: 16px;">
|
||||
<span>عرض المعالم / Show POIs</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- DB NAMES INDICATOR -->
|
||||
<div id="db-status">
|
||||
<div class="dot-gold"></div>
|
||||
@@ -325,8 +340,8 @@
|
||||
/* ───────────────────────────────────────────
|
||||
CONFIG
|
||||
─────────────────────────────────────────── */
|
||||
const API_BASE = 'https://map-saas.intaleqapp.com';
|
||||
const API_KEY = 'intaleq_secret_2026';
|
||||
const API_BASE = window.location.origin; // Dynamically use the current host
|
||||
const API_KEY = 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
||||
const HEADERS = { 'x-api-key': API_KEY };
|
||||
|
||||
const ROUTES = {
|
||||
@@ -650,6 +665,27 @@
|
||||
info.textContent = '❌ خطأ في الاتصال بالسيرفر.';
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────────────────────────────────────
|
||||
LAYER CONTROLS
|
||||
─────────────────────────────────────────── */
|
||||
function updateLayers() {
|
||||
const show3D = document.getElementById('toggle-3d').checked;
|
||||
const showPOIs = document.getElementById('toggle-pois').checked;
|
||||
|
||||
if (map.getLayer('3d-buildings')) {
|
||||
map.setLayoutProperty('3d-buildings', 'visibility', show3D ? 'visible' : 'none');
|
||||
}
|
||||
|
||||
if (map.getLayer('intaleq-db-label')) {
|
||||
map.setLayoutProperty('intaleq-db-label', 'visibility', showPOIs ? 'visible' : 'none');
|
||||
}
|
||||
|
||||
// Also toggle the dynamic POIs icons layer if exists
|
||||
if (map.getLayer('intaleq_pois')) {
|
||||
map.setLayoutProperty('intaleq_pois', 'visibility', showPOIs ? 'visible' : 'none');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1419
-199
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
Map as MapIcon,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
LayoutDashboard,
|
||||
ExternalLink,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
|
||||
const IntelligenceDashboard: React.FC = () => {
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [candidates, setCandidates] = useState<any[]>([]);
|
||||
const [closures, setClosures] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'summary' | 'candidates' | 'closures'>('summary');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const API_BASE = (import.meta as any).env.VITE_API_URL || '/api';
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [summaryRes, candRes, closeRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/map-refinement/summary`),
|
||||
fetch(`${API_BASE}/map-refinement/candidates?status=pending`),
|
||||
fetch(`${API_BASE}/map-refinement/closures`)
|
||||
]);
|
||||
|
||||
const summary = await summaryRes.json();
|
||||
const cand = await candRes.json();
|
||||
const close = await closeRes.json();
|
||||
|
||||
setStats(summary);
|
||||
setCandidates(cand || []);
|
||||
setClosures(close || []);
|
||||
} catch (error) {
|
||||
console.error("Dashboard fetch failed", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const runAnalysis = async () => {
|
||||
setMessage('Brain starting deep analysis... 🧠');
|
||||
try {
|
||||
await fetch(`${API_BASE}/telemetry/process-intelligence?days=2`, { method: 'POST' });
|
||||
setMessage('Analysis initiated successfully. Check Telegram for report.');
|
||||
} catch (error) {
|
||||
setMessage('Analysis failed to start.');
|
||||
}
|
||||
setTimeout(() => setMessage(''), 5000);
|
||||
};
|
||||
|
||||
const handleCandidate = async (id: string, action: 'approve' | 'reject') => {
|
||||
try {
|
||||
await fetch(`${API_BASE}/map-refinement/candidates/${id}/${action}`, { method: 'PATCH' });
|
||||
setMessage(`Road ${action}d successfully. 🛣️`);
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
setMessage(`Failed to ${action} road.`);
|
||||
}
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="intel-dashboard" style={{
|
||||
color: '#f8fafc',
|
||||
padding: '2rem',
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
animation: 'fadeIn 0.5s ease'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2.5rem' }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '1.8rem', fontWeight: 700, margin: 0, display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ background: 'linear-gradient(135deg, #6366f1, #3b82f6)', padding: '10px', borderRadius: '12px' }}>
|
||||
<LayoutDashboard size={24} color="white" />
|
||||
</div>
|
||||
Map AI Intelligence
|
||||
</h1>
|
||||
<p style={{ color: '#94a3b8', margin: '4px 0 0 0', fontSize: '0.9rem' }}>Intaleq SaaS Monitoring & Enrichment Platform</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={runAnalysis}
|
||||
className="btn-intel"
|
||||
style={{
|
||||
background: '#6366f1',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={18} /> Run Intelligence
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Stats Quick View */}
|
||||
<section style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '1.5rem', marginBottom: '2rem' }}>
|
||||
{[
|
||||
{ label: 'Telemetry Points', value: stats?.telemetry?.total || 0, icon: <Activity size={20} color="#818cf8" />, color: '#818cf8' },
|
||||
{ label: 'Analyzed Segments', value: stats?.roads?.analyzed || 0, icon: <MapIcon size={20} color="#60a5fa" />, color: '#60a5fa' },
|
||||
{ label: 'Road Candidates', value: candidates.length, icon: <Settings size={20} color="#fbbf24" />, color: '#fbbf24' },
|
||||
{ label: 'Active Closures', value: closures.length, icon: <ShieldAlert size={20} color="#f87171" />, color: '#f87171' },
|
||||
].map((stat, i) => (
|
||||
<div key={i} style={{
|
||||
background: 'rgba(30, 41, 59, 0.7)',
|
||||
border: '1px solid #334155',
|
||||
borderRadius: '16px',
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
<div style={{ background: `${stat.color}15`, padding: '12px', borderRadius: '12px' }}>{stat.icon}</div>
|
||||
<div>
|
||||
<span style={{ fontSize: '0.8rem', color: '#94a3b8', display: 'block' }}>{stat.label}</span>
|
||||
<span style={{ fontSize: '1.4rem', fontWeight: 700 }}>{stat.value.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div style={{
|
||||
background: 'rgba(30, 41, 59, 0.7)',
|
||||
border: '1px solid #334155',
|
||||
borderRadius: '24px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Navigation Tabs */}
|
||||
<nav style={{ display: 'flex', borderBottom: '1px solid #334155', background: 'rgba(15, 23, 42, 0.3)' }}>
|
||||
{[
|
||||
{ id: 'summary', label: 'Analysis Summary' },
|
||||
{ id: 'candidates', label: `Candidates (${candidates.length})` },
|
||||
{ id: 'closures', label: `Closures (${closures.length})` }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '1.2rem',
|
||||
border: 'none',
|
||||
background: activeTab === tab.id ? 'rgba(99, 102, 241, 0.1)' : 'transparent',
|
||||
color: activeTab === tab.id ? '#818cf8' : '#94a3b8',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
borderBottom: activeTab === tab.id ? '2px solid #818cf8' : '2px solid transparent',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div style={{ padding: '2rem', minHeight: '400px' }}>
|
||||
{activeTab === 'candidates' && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ margin: 0 }}>Detected Road Candidates / طرق مرشحة</h3>
|
||||
<div style={{ display: 'flex', gap: '8px', background: '#1e293b', padding: '6px 12px', borderRadius: '10px' }}>
|
||||
<Search size={16} color="#94a3b8" />
|
||||
<input type="text" placeholder="Filter candidates..." style={{ background: 'none', border: 'none', color: 'white', outline: 'none', fontSize: '0.85rem' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{candidates.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '4rem', color: '#64748b' }}>
|
||||
<Activity size={48} style={{ opacity: 0.1, marginBottom: '1rem' }} />
|
||||
<p>No pending road candidates found. Everything is synced. ✅</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
{candidates.map((c) => (
|
||||
<div key={c.id} style={{
|
||||
background: '#1e293b',
|
||||
borderRadius: '16px',
|
||||
padding: '1.2rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
border: '1px solid #334155'
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '20px', alignItems: 'center' }}>
|
||||
<div style={{
|
||||
width: '4px',
|
||||
height: '40px',
|
||||
background: c.confidence > 0.8 ? '#22c55e' : '#eab308',
|
||||
borderRadius: '4px'
|
||||
}} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '1rem', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
New Segment {c.id.slice(-4)}
|
||||
<span style={{
|
||||
fontSize: '0.7rem',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '20px',
|
||||
color: '#94a3b8'
|
||||
}}>
|
||||
{Math.round(c.lengthMeters)}m
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: '#94a3b8', fontSize: '0.8rem', marginTop: '4px' }}>
|
||||
{c.uniqueDriverCount} drivers • {c.totalPoints} points • Confidence: {Math.round(c.confidence * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button onClick={() => handleCandidate(c.id, 'approve')} style={{ background: 'rgba(34, 197, 94, 0.1)', color: '#4ade80', border: '1px solid #22c55e33', padding: '8px 16px', borderRadius: '8px', cursor: 'pointer', fontWeight: 600 }}>Approve</button>
|
||||
<button onClick={() => handleCandidate(c.id, 'reject')} style={{ background: 'rgba(239, 68, 68, 0.1)', color: '#f87171', border: '1px solid #ef444433', padding: '8px 16px', borderRadius: '8px', cursor: 'pointer', fontWeight: 600 }}>Ignore</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'summary' && stats && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1.2fr', gap: '2rem' }}>
|
||||
<div>
|
||||
<h3 style={{ marginBottom: '1.5rem' }}>Intelligence Quality Report</h3>
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
{/* Quality Metrics */}
|
||||
{[
|
||||
{ label: 'Data Density', value: 'High Accuracy', desc: 'Clusters are well-defined in Damascus & Amman', color: '#22c55e' },
|
||||
{ label: 'Map Freshness', value: 'Last 24h', desc: 'Telemetry analysis window is fully processed', color: '#6366f1' },
|
||||
{ label: 'Overpass Sync', value: 'Available', desc: 'Overture enrichment ready to pull labels', color: '#3b82f6' }
|
||||
].map((item, i) => (
|
||||
<div key={i} style={{ background: '#1e293b', padding: '1.2rem', borderRadius: '16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{item.label}</div>
|
||||
<div style={{ color: '#94a3b8', fontSize: '0.8rem' }}>{item.desc}</div>
|
||||
</div>
|
||||
<div style={{ color: item.color, fontWeight: 700, fontSize: '0.9rem' }}>{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ background: '#1e293b', borderRadius: '24px', padding: '1.5rem', border: '1px solid #334155' }}>
|
||||
<h4 style={{ margin: '0 0 1rem 0' }}>Action Log</h4>
|
||||
<div style={{ fontSize: '0.85rem', color: '#94a3b8', display: 'grid', gap: '12px' }}>
|
||||
{stats.history?.map((entry: any, i: number) => (
|
||||
<div key={i} style={{ display: 'flex', gap: '10px' }}>
|
||||
<ChevronRight size={14} />
|
||||
{entry}
|
||||
</div>
|
||||
)) || [<div key="0">System initialized. Waiting for analysis...</div>]}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'closures' && (
|
||||
<div style={{ textAlign: 'center', padding: '4rem', color: '#64748b' }}>
|
||||
<XCircle size={48} style={{ opacity: 0.1, marginBottom: '1rem' }} color="#f87171" />
|
||||
<p>No active road closures detected in current telemetry. 🚧</p>
|
||||
<button className="btn-intel" style={{ background: 'transparent', border: '1px solid #334155', color: '#94a3b8', padding: '8px 16px', borderRadius: '8px', cursor: 'pointer', marginTop: '1rem' }}>Trigger Manual Closure Scan</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Persistence Message */}
|
||||
{message && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '2rem',
|
||||
right: '2rem',
|
||||
background: '#0f172a',
|
||||
border: '1px solid #6366f1',
|
||||
padding: '12px 24px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.5)',
|
||||
animation: 'slideIn 0.3s ease-out',
|
||||
zIndex: 1000,
|
||||
color: 'white',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
`}} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntelligenceDashboard;
|
||||
@@ -0,0 +1,64 @@
|
||||
services:
|
||||
# Database: PostgreSQL + PostGIS (Map-2)
|
||||
db2:
|
||||
image: postgis/postgis:15-3.3
|
||||
container_name: map2-db
|
||||
platform: linux/amd64
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-map2_postgres_secure_root_password_32}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-map2_db}
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres_data2:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Cache: Redis (Map-2)
|
||||
redis2:
|
||||
image: redis:7-alpine
|
||||
container_name: map2-redis
|
||||
platform: linux/amd64
|
||||
ports:
|
||||
- "6382:6379"
|
||||
volumes:
|
||||
- redis_data2:/data
|
||||
|
||||
# Routing Engine: GraphHopper (Map-2)
|
||||
routing2:
|
||||
image: israelhikingmap/graphhopper:latest
|
||||
container_name: map2-routing
|
||||
platform: linux/amd64
|
||||
ports:
|
||||
- "8990:8080"
|
||||
environment:
|
||||
- JAVA_OPTS=-Xmx2g -Xms512m
|
||||
volumes:
|
||||
- ./infrastructure/osm-data-v2:/data
|
||||
- ./infrastructure/docker/graphhopper/config.yml:/graphhopper/config.yml
|
||||
command: ["-i", "/data/map2_enriched.osm.pbf", "-c", "config.yml"]
|
||||
depends_on:
|
||||
- db2
|
||||
|
||||
# Tile Server: Martin (Map-2)
|
||||
# يقدم الخرائط من قاعدة البيانات الجديدة
|
||||
martin2:
|
||||
image: maplibre/martin:latest
|
||||
container_name: map2-martin
|
||||
platform: linux/amd64
|
||||
ports:
|
||||
- "3203:3000"
|
||||
environment:
|
||||
- WATCH_DB=true
|
||||
command: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-intaleq2026}@db2:5432/${POSTGRES_DB:-map2_db}
|
||||
depends_on:
|
||||
db2:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres_data2:
|
||||
redis_data2:
|
||||
@@ -100,6 +100,8 @@ services:
|
||||
- LOCATION_SERVER_API_KEY=${LOCATION_SERVER_API_KEY}
|
||||
- GRAPH_HOPPER_URL=${GRAPH_HOPPER_URL}
|
||||
- TILE_SERVER_URL=${TILE_SERVER_URL}
|
||||
volumes:
|
||||
- .:/data
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
|
||||
# overture_ingest.sh - Data pipeline for Overture Maps
|
||||
# سكريبت سحب وحقن بيانات المباني والشوارع
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
DB_HOST="localhost"
|
||||
DB_PORT="5432"
|
||||
DB_USER="mapuser"
|
||||
DB_NAME="mapdb"
|
||||
DB_PASS="mappass"
|
||||
|
||||
# Jordan North BBOX (Amman, Irbid, Zarqa)
|
||||
BBOX_JORDAN_NORTH="35.5,31.7,39.3,33.4"
|
||||
# Jordan South BBOX (Aqaba, Karak, Ma'an)
|
||||
BBOX_JORDAN_SOUTH="34.9,29.1,38.0,31.7"
|
||||
# Syria BBOX (Full Country)
|
||||
BBOX_SYRIA="35.7,32.3,42.4,37.3"
|
||||
|
||||
echo "🚀 Starting Overture Data Pipeline (Jordan & Syria)..."
|
||||
|
||||
# 1. Setup Python Environment
|
||||
# ... (rest of setup)
|
||||
if [ ! -d "venv_overture" ]; then
|
||||
echo "📦 Creating virtual environment..."
|
||||
python3 -m venv venv_overture
|
||||
fi
|
||||
source venv_overture/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install overturemaps
|
||||
|
||||
# 2. Function to download and ingest
|
||||
process_city() {
|
||||
local city=$1
|
||||
local bbox=$2
|
||||
local theme=$3 # building, segment, or division_area
|
||||
|
||||
echo "🌍 Processing $city - Theme: $theme..."
|
||||
output_file="overture_${city}_${theme}.geojson"
|
||||
|
||||
# Download
|
||||
overturemaps download --bbox="$bbox" -f geojson --type="$theme" -o "$output_file"
|
||||
|
||||
# If division_area, we use our NestJS API to import for better control
|
||||
if [ "$theme" == "division_area" ]; then
|
||||
echo "📍 Administrative data downloaded: $output_file"
|
||||
echo "💡 Use the NestJS /geocoding/import-boundaries endpoint to ingest this file."
|
||||
return
|
||||
fi
|
||||
|
||||
# Ingest into PostGIS for buildings and segments
|
||||
echo "🔌 Injecting into PostGIS (Table: overture_${theme})..."
|
||||
export PGPASSWORD=$DB_PASS
|
||||
ogr2ogr -f "PostgreSQL" \
|
||||
PG:"host=$DB_HOST port=$DB_PORT user=$DB_USER dbname=$DB_NAME password=$DB_PASS" \
|
||||
"$output_file" \
|
||||
-nln "overture_${theme}" \
|
||||
-update -append \
|
||||
-nlt PROMOTE_TO_MULTI \
|
||||
-lco GEOMETRY_NAME=location
|
||||
|
||||
echo "✅ Finished $city $theme"
|
||||
rm "$output_file"
|
||||
}
|
||||
|
||||
# 3. Execution (Jordan & Syria)
|
||||
# Jordan
|
||||
process_city "jordan_north" "$BBOX_JORDAN_NORTH" "division_area"
|
||||
process_city "jordan_south" "$BBOX_JORDAN_SOUTH" "division_area"
|
||||
|
||||
# Syria
|
||||
process_city "syria" "$BBOX_SYRIA" "division_area"
|
||||
|
||||
echo "🎉 Administrative boundary GeoJSONs ready for import!"
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
|
||||
# setup_map2.sh - Automated setup for the isolated Map-2 environment
|
||||
# إعداد بيئة الخرائط الثانية المعزولة بالكامل
|
||||
|
||||
echo "🚀 Starting Map-2 Environment Setup..."
|
||||
|
||||
# 1. Create Data Directory on Server
|
||||
echo "📂 Preparing data directory: infrastructure/osm-data-v2"
|
||||
mkdir -p infrastructure/osm-data-v2
|
||||
|
||||
# 2. Check for PBF file
|
||||
if [ ! -f "infrastructure/osm-data-v2/map2_enriched.osm.pbf" ]; then
|
||||
echo "⚠️ No map2_enriched.osm.pbf found."
|
||||
echo "💡 Copying existing master_map as a starting point..."
|
||||
cp infrastructure/osm-data/master_map.osm.pbf infrastructure/osm-data-v2/map2_enriched.osm.pbf
|
||||
fi
|
||||
|
||||
# 3. Spin up the containers
|
||||
echo "🏗️ Starting Map-2 Stack (DB, Redis, Tiles, Routing)..."
|
||||
docker compose -f docker-compose.map2.yml up -d
|
||||
|
||||
echo "✅ Map-2 is being provisioned."
|
||||
echo "------------------------------------------------"
|
||||
echo "Ports used:"
|
||||
echo " - Martin (Tiles): 3203"
|
||||
echo " - Database: 5433"
|
||||
echo " - Routing: 8990"
|
||||
echo " - Redis: 6382"
|
||||
echo "------------------------------------------------"
|
||||
echo "Intelligence Roadmap:"
|
||||
echo "1. Run Overture downloader (Next step)"
|
||||
echo "2. Inject Arabic names via Overpass API"
|
||||
echo "3. Connect Martin to the new DB"
|
||||
+3
-1
@@ -18,7 +18,7 @@ else
|
||||
fi
|
||||
|
||||
# Sync files using rsync (surgical sync)
|
||||
rsync -avz --progress $KEY_FLAG \
|
||||
rsync -avz --progress -e "ssh -o StrictHostKeyChecking=no" $KEY_FLAG \
|
||||
--exclude 'infrastructure/osm-data' \
|
||||
--exclude 'osm-data' \
|
||||
--exclude 'venv' \
|
||||
@@ -27,6 +27,8 @@ rsync -avz --progress $KEY_FLAG \
|
||||
--exclude '.DS_Store' \
|
||||
.env \
|
||||
docker-compose.yml \
|
||||
docker-compose.map2.yml \
|
||||
setup_map2.sh \
|
||||
apps \
|
||||
packages \
|
||||
infrastructure \
|
||||
|
||||
Reference in New Issue
Block a user