Final Refinement: Native Labels, Interactive Management UI, and Visual Polishing

This commit is contained in:
Hamza-Ayed
2026-03-29 02:55:28 +03:00
parent 014848247d
commit ca043b67a8
10 changed files with 5141 additions and 1367 deletions
@@ -38,17 +38,30 @@ export class GeocodingInitService implements OnModuleInit {
FOR EACH ROW EXECUTE FUNCTION sync_place_location();
`);
// 1. GIST Geometry Indexes (for fast proximity search)
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_places_syria_location ON places_syria USING gist (location);');
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_osm_areas_geom ON osm_areas USING gist (geom);');
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_osm_points_geom ON osm_points_with_area USING gist (geom);');
// 3. Triggers for Jordan and Egypt
await this.repo.query(`
DROP TRIGGER IF EXISTS trg_sync_place_location_jordan ON places_jordan;
CREATE TRIGGER trg_sync_place_location_jordan
BEFORE INSERT OR UPDATE ON places_jordan
FOR EACH ROW EXECUTE FUNCTION sync_place_location();
`);
// 2. GIST Trigram Indexes (for fast fuzzy name search and low server load)
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_places_syria_names_trgm ON places_syria USING gist (name_ar gist_trgm_ops, name_en gist_trgm_ops);');
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_osm_areas_names_trgm ON osm_areas USING gist (name_ar gist_trgm_ops);');
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_osm_points_names_trgm ON osm_points_with_area USING gist (name_ar gist_trgm_ops, name_en gist_trgm_ops);');
await this.repo.query(`
DROP TRIGGER IF EXISTS trg_sync_place_location_egypt ON places_egypt;
CREATE TRIGGER trg_sync_place_location_egypt
BEFORE INSERT OR UPDATE ON places_egypt
FOR EACH ROW EXECUTE FUNCTION sync_place_location();
`);
this.logger.log('Geocoding database triggers and optimized indexes initialized.');
// 4. GIST Geometry Indexes for Jordan and Egypt
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_places_jordan_location ON places_jordan USING gist (location);');
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_places_egypt_location ON places_egypt USING gist (location);');
// 5. GIST Trigram Indexes for Jordan and Egypt
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_places_jordan_names_trgm ON places_jordan USING gist (name_ar gist_trgm_ops, name_en gist_trgm_ops);');
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_places_egypt_names_trgm ON places_egypt USING gist (name_ar gist_trgm_ops, name_en gist_trgm_ops);');
this.logger.log('Geocoding database triggers and optimized indexes initialized for Syria, Jordan, and Egypt.');
} catch (err) {
this.logger.error('Failed to initialize database geocoding triggers:', err);
}
+29 -2
View File
@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
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 { ApiKeyGuard } from '../common/guards/api-key.guard';
@@ -15,13 +15,15 @@ export class GeocodingController {
@ApiQuery({ name: 'lat', required: false, type: Number })
@ApiQuery({ name: 'lng', required: false, type: Number })
@ApiQuery({ name: 'radius', required: false, type: Number })
@ApiQuery({ name: 'country', required: false, enum: ['jordan', 'syria', 'egypt'] })
async search(
@Query('q') query: string,
@Query('lat') lat?: number,
@Query('lng') lng?: number,
@Query('radius') radius?: number,
@Query('country') country?: string,
) {
return this.geocodingService.searchPlaces(query, lat, lng, radius);
return this.geocodingService.searchPlaces(query, lat, lng, radius, country);
}
@Get('reverse')
@@ -38,6 +40,25 @@ export class GeocodingController {
return this.geocodingService.addPlace(placeData);
}
@Delete('places')
@ApiOperation({ summary: 'Delete a place by name or ID' })
@ApiQuery({ name: 'name', required: false })
@ApiQuery({ name: 'id', required: false, type: Number })
@ApiQuery({ name: 'country', required: true, enum: ['jordan', 'syria', 'egypt'] })
async deletePlace(
@Query('country') country: string,
@Query('name') name?: string,
@Query('id') id?: number,
) {
if (id) {
return this.geocodingService.deletePlaceById(Number(id), country);
}
if (name) {
return this.geocodingService.deletePlacesByName(name, country);
}
throw new HttpException('Name or ID required', HttpStatus.BAD_REQUEST);
}
@Post('upsert-place')
@ApiOperation({ summary: 'Add or Update a location (Automated Scraper)' })
async upsertPlace(@Body() placeData: any) {
@@ -55,4 +76,10 @@ export class GeocodingController {
async getPlaces(@Query('limit') limit?: number) {
return this.geocodingService.getRecentPlaces(limit);
}
@Get('geojson')
@ApiOperation({ summary: 'Get all user submitted places as GeoJSON for Map Style' })
async getGeoJSON() {
return this.geocodingService.getAllPlacesGeoJSON();
}
}
+144 -21
View File
@@ -45,44 +45,91 @@ export class GeocodingService {
return 'places_syria';
}
async searchPlaces(query: string, lat?: number, lon?: number, radius: number = 20000) {
async searchPlaces(
query: string,
lat?: number,
lon?: number,
radius: number = 20000,
country?: string,
) {
try {
const cleanQuery = query.trim();
const hasLocation = lat !== undefined && lon !== undefined;
// If we have no location, we search ALL three tables (Syria, Jordan, Egypt)
// وإلا فسنقوم بالبحث في المنطقة المناسبة فقط لزيادة السرعة والدقة
const tables = hasLocation ? [this.getTableNameForRepo(this.getRepositoryForCoords(lat, lon))] : ['places_syria', 'places_jordan', 'places_egypt'];
const allResults: any[] = [];
const ILikeQuery = `%${cleanQuery}%`;
const allResults: any[] = [];
for (const tableName of tables) {
const repo = tableName === 'places_jordan' ? this.placesJordanRepository :
tableName === 'places_egypt' ? this.placesEgyptRepository :
this.placesSyriaRepository;
// تحويل المسافة بالامتار الى درجات جغرافية تقريبية للفلترة السريعة
const radiusInDegrees = radius / 111000;
const userPlacesQuery = `
// 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(${lon}, ${lat}), 4326)` : 'NULL';
// 2. البحث في جداول المستخدم (Syria, Egypt only)
const userTables = targetRegion
? (['syria', 'egypt'].includes(targetRegion) ? [`places_${targetRegion}`] : [])
: ['places_syria', 'places_egypt'];
for (const tableName of userTables) {
const repo = tableName === 'places_egypt' ? this.placesEgyptRepository : this.placesSyriaRepository;
const userQuery = `
SELECT id, name, name_ar, name_en, category, latitude, longitude, address, 'user_submitted' as source,
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3, $2), 4326)) ELSE 0 END as distance,
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ${userPointSql}) ELSE 0 END as distance,
similarity(COALESCE(name_ar, ''), $1) + similarity(COALESCE(name_en, ''), $1) as relevance
FROM ${tableName}
WHERE (name_ar % $1 OR name_en % $1 OR name % $1 OR name_ar ILIKE $4 OR name_en ILIKE $4)
${hasLocation ? `AND ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3, $2), 4326)) <= $5` : ''}
ORDER BY distance ASC, relevance DESC LIMIT 10
${hasLocation ? `AND location && ST_Expand(${userPointSql}, $5) AND ST_DistanceSphere(location, ${userPointSql}) <= $6` : ''}
ORDER BY relevance DESC, distance ASC LIMIT 15
`;
const params = [cleanQuery, lat || null, lon || null, ILikeQuery, radius];
const results = await repo.query(userPlacesQuery, params);
const results = await repo.query(userQuery, [cleanQuery, lat || null, lon || null, ILikeQuery, radiusInDegrees, radius]);
allResults.push(...results);
}
// Sort combined results by relevance and distance
const sortedResults = allResults.sort((a, b) => b.relevance - a.relevance || a.distance - b.distance).slice(0, 15);
// 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)';
}
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,
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) AND ST_DistanceSphere(geom, ${userPointSql}) <= $6` : ''}
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);
})
.slice(0, 20);
this.logger.debug(`Spatial Search complete (Sorted by ${hasLocation ? 'Distance' : 'Relevance'}). Results: ${sortedResults.length}`);
return { results: sortedResults };
} catch (e) {
this.logger.error('Search failed:', e);
this.logger.error('Optimized Spatial Search failed:', e);
return { results: [] };
}
}
@@ -190,4 +237,80 @@ export class GeocodingService {
.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: [] };
}
}
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);
}
}
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);
}
}
private getRepoByCountry(country: string) {
switch (country?.toLowerCase()) {
case 'syria': return this.placesSyriaRepository;
case 'jordan': return this.placesJordanRepository;
case 'egypt': return this.placesEgyptRepository;
default: throw new HttpException('Invalid country: ' + country, HttpStatus.BAD_REQUEST);
}
}
}
+655
View File
@@ -0,0 +1,655 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Intaleq Premium Map V3 - خرائط انطلاقة الذكية</title>
<link href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" rel="stylesheet" />
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;800&family=Noto+Sans+Arabic:wght@400;700&display=swap');
:root {
--primary: #c0a048;
--primary-dark: #8a6e20;
--accent: #2563eb;
--bg-glass: rgba(255, 255, 255, 0.88);
--shadow: 0 12px 40px rgba(0,0,0,0.12);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Outfit', 'Noto Sans Arabic', sans-serif;
background: #F2EDE4;
overflow: hidden;
color: #342C20;
}
/* ── MAP ── */
#map { width: 100vw; height: 100vh; position: absolute; inset: 0; }
/* ── LOADING OVERLAY ── */
#loading {
display: none; position: fixed; inset: 0;
background: rgba(242,237,228,0.6);
z-index: 2000; align-items: center; justify-content: center;
backdrop-filter: blur(6px);
}
.spinner {
width: 40px; height: 40px;
border: 4px solid rgba(192,160,72,0.15);
border-top: 4px solid var(--primary);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── CONTROLS PANEL ── */
.controls {
position: absolute; top: 24px; right: 24px; z-index: 100;
background: var(--bg-glass);
backdrop-filter: blur(14px) saturate(180%);
-webkit-backdrop-filter: blur(14px) saturate(180%);
border-radius: 24px; padding: 28px; width: 380px;
box-shadow: var(--shadow);
border: 1px solid rgba(255,255,255,0.35);
}
.badge {
display: inline-block; padding: 4px 10px; border-radius: 6px;
font-size: 11px; font-weight: 800;
background: var(--primary); color: #fff;
margin-bottom: 12px; text-transform: uppercase; letter-spacing: 1px;
}
.panel-title { font-size: 22px; font-weight: 800; margin-bottom: 6px; color: #18100A; }
.panel-desc { font-size: 13px; color: #584840; margin-bottom: 20px; line-height: 1.55; }
/* ── SEARCH ── */
.search-wrap { position: relative; margin-bottom: 20px; }
.search-wrap input {
width: 100%; padding: 13px 60px 13px 16px;
border-radius: 12px; border: 1.5px solid #ddd;
font-family: inherit; font-size: 14px;
outline: none; transition: border-color 0.25s;
background: rgba(255,255,255,0.7);
}
.search-wrap input:focus { border-color: var(--primary); }
.search-wrap .search-btn {
position: absolute; left: 8px; top: 50%; transform: translateY(-50%);
background: var(--primary); color: #fff;
border: none; padding: 6px 13px; border-radius: 8px;
cursor: pointer; font-size: 12px; font-weight: 700;
transition: background 0.2s;
}
.search-wrap .search-btn:hover { background: var(--primary-dark); }
#search-results {
display: none; max-height: 220px; overflow-y: auto;
background: #fff; border-radius: 12px;
border: 1px solid #eee;
box-shadow: 0 4px 14px rgba(0,0,0,0.06);
margin-top: 8px;
}
.result-item {
padding: 11px 16px; border-bottom: 1px solid #f5f5f5;
cursor: pointer; transition: background 0.15s;
}
.result-item:last-child { border-bottom: none; }
.result-item:hover { background: #fdfaf5; }
.result-item .name { font-weight: 700; font-size: 13px; }
.result-item .meta { font-size: 11px; color: #999; margin-top: 2px; }
/* ── ROUTE BUTTONS ── */
.btn-group { display: flex; flex-direction: column; gap: 10px; }
.btn {
padding: 15px 18px; border: none; border-radius: 14px;
font-size: 14px; font-weight: 700; cursor: pointer;
display: flex; align-items: center; justify-content: space-between;
transition: transform 0.25s, box-shadow 0.25s;
font-family: inherit;
}
.btn .label-sub { font-size: 11px; font-weight: 400; opacity: 0.75; margin-bottom: 2px; }
.btn .icon { font-size: 20px; }
.btn-gold {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
color: #fff;
box-shadow: 0 4px 16px rgba(192,160,72,0.28);
}
.btn-gold:hover { transform: translateY(-2px); box-shadow: 0 8px 22px rgba(192,160,72,0.38); }
.btn-white {
background: #fff; color: #342C20;
border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 3px 8px rgba(0,0,0,0.04);
}
.btn-white:hover { background: #fdfaf5; transform: translateY(-2px); }
/* ── STATUS BOX ── */
#status {
display: none; margin-top: 20px;
background: rgba(253,250,245,0.7);
border: 1.5px dashed var(--primary);
border-radius: 14px; padding: 16px; font-size: 13px;
line-height: 1.65;
animation: fadeUp 0.35s ease-out;
}
.status-row {
display: flex; justify-content: space-between; margin-top: 6px;
}
.status-row span:last-child { font-weight: 800; }
/* ── DB NAMES INDICATOR ── */
#db-status {
margin-top: 14px; padding: 10px 14px;
background: rgba(192,160,72,0.08);
border-radius: 10px; font-size: 12px; color: #7A5C10;
display: flex; align-items: center; gap: 8px;
}
.dot-gold {
width: 8px; height: 8px; border-radius: 50%;
background: var(--primary); flex-shrink: 0;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── BRANDING ── */
.branding {
position: absolute; bottom: 28px; left: 28px; z-index: 100;
background: var(--bg-glass); backdrop-filter: blur(8px);
padding: 10px 18px; border-radius: 12px;
display: flex; align-items: center; gap: 10px;
box-shadow: 0 6px 20px rgba(0,0,0,0.09);
border: 1px solid rgba(255,255,255,0.4);
}
.branding span { font-size: 12px; font-weight: 800; color: #18100A; letter-spacing: 0.5px; }
/* ── CONTEXT MENU ── */
#context-menu {
display: none; position: fixed; z-index: 3000;
background: var(--bg-glass); backdrop-filter: blur(12px);
border-radius: 12px; min-width: 180px;
box-shadow: 0 8px 30px rgba(0,0,0,0.15);
border: 1px solid rgba(255,255,255,0.4);
padding: 6px;
}
.menu-item {
padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 700;
cursor: pointer; display: flex; align-items: center; gap: 10px;
transition: background 0.2s;
}
.menu-item:hover { background: rgba(192,160,72,0.12); color: var(--primary-dark); }
.menu-item .icon { font-size: 16px; }
/* ── ADMIN PANEL ── */
#admin-panel {
position: absolute; bottom: 84px; right: 24px; z-index: 100;
background: var(--bg-glass); backdrop-filter: blur(14px);
border-radius: 20px; padding: 20px; width: 340px;
box-shadow: var(--shadow); border: 1px solid rgba(255,255,255,0.3);
display: none; animation: fadeUp 0.3s;
}
.admin-title { font-size: 16px; font-weight: 800; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
.admin-close { cursor: pointer; opacity: 0.5; margin-right: auto; font-size: 18px; }
.delete-group { margin-top: 15px; }
.delete-group label { display: block; font-size: 11px; font-weight: 700; margin-bottom: 6px; opacity: 0.7; }
.delete-group input, .delete-group select {
width: 100%; padding: 10px; border-radius: 8px; border: 1.5px solid #eee;
font-size: 13px; margin-bottom: 10px; outline: none;
}
.delete-group input:focus { border-color: #c0392b; }
.btn-delete {
width: 100%; padding: 10px; background: #c0392b; color: #fff; border: none;
border-radius: 10px; font-weight: 800; cursor: pointer;
}
#toggle-admin {
position: absolute; top: 110px; left: 10px; z-index: 100;
background: var(--bg-glass); border: none; border-radius: 50%;
width: 44px; height: 44px; display: flex; align-items: center; justify-content: center;
box-shadow: var(--shadow); cursor: pointer; font-size: 20px;
}
/* ── SCROLLBAR ── */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--primary); border-radius: 10px; }
</style>
</head>
<body>
<!-- MAP -->
<div id="map"></div>
<!-- LOADING -->
<div id="loading">
<div style="text-align:center">
<div class="spinner" style="margin: 0 auto 14px;"></div>
<strong style="color:var(--primary-dark)">جاري حساب المسار الذكي...</strong>
</div>
</div>
<!-- BRANDING -->
<div class="branding">
<img src="/intaleq-logo.png" alt="Logo" style="height:22px" onerror="this.style.display='none'">
<span>INTALEQ PREMIUM MAPS</span>
</div>
<!-- CONTROLS -->
<div class="controls">
<div class="badge">v3.1.0</div>
<div class="panel-title">🗺️ خرائط انطلاقة الذكية</div>
<p class="panel-desc">حلول جغرافية متقدمة لمنطقة الشرق الأوسط وشمال أفريقيا — عرض ثلاثي الأبعاد وأسماء مباشرة من قاعدة البيانات.</p>
<!-- SEARCH -->
<div class="search-wrap">
<input id="search-input" type="text" placeholder="ابحث عن مكان... (مسجد، مستشفى، ...)" />
<button class="search-btn" onclick="performSearch()">بحث</button>
<div id="search-results"></div>
</div>
<!-- ROUTE BUTTONS -->
<div class="btn-group">
<button class="btn btn-gold" onclick="handleRoute('LEVANT')">
<div>
<div class="label-sub">مسار مقترح — سوريا</div>
<span>عمّان ← دمشق</span>
</div>
<span class="icon">🚀</span>
</button>
<button class="btn btn-white" onclick="handleRoute('EGYPT')">
<div>
<div class="label-sub">مسار مقترح — مصر</div>
<span>شبرا ← الزمالك</span>
</div>
<span class="icon">🇪🇬</span>
</button>
</div>
<!-- STATUS -->
<div id="status"></div>
<!-- DB NAMES INDICATOR -->
<div id="db-status">
<div class="dot-gold"></div>
<span id="db-status-text">جاري تحميل الأسماء من قاعدة البيانات...</span>
</div>
</div>
<!-- TOGGLE ADMIN -->
<button id="toggle-admin" onclick="toggleAdminPanel()" title="إدارة البيانات">⚙️</button>
<!-- CONTEXT MENU -->
<div id="context-menu">
<div class="menu-item" onclick="copyCoords()">
<span class="icon">📋</span> <span>نسخ الإحداثيات</span>
</div>
<div class="menu-item" id="menu-delete-btn" style="color:#c0392b" onclick="deleteFromContext()">
<span class="icon">🗑️</span> <span>حذف هذا الموقع</span>
</div>
</div>
<!-- ADMIN PANEL -->
<div id="admin-panel">
<div class="admin-title">
<span>🛡️ إدارة البيانات</span>
<span class="admin-close" onclick="toggleAdminPanel()">×</span>
</div>
<div class="delete-group">
<label>حذف موقع بواسطة الاسم</label>
<input type="text" id="admin-delete-name" placeholder="أدخل اسم الموقع..." />
<label>الدولة</label>
<select id="admin-delete-country">
<option value="syria">سوريا</option>
<option value="jordan">الأردن</option>
<option value="egypt">مصر</option>
</select>
<button class="btn-delete" onclick="handleAdminDelete()">حذف الموقع نهائياً</button>
</div>
<div id="admin-info" style="font-size:11px; margin-top:10px; opacity:0.6; text-align:center"></div>
</div>
<script>
/* ───────────────────────────────────────────
CONFIG
─────────────────────────────────────────── */
const API_BASE = 'https://map-saas.intaleqapp.com';
const API_KEY = 'intaleq_secret_2026';
const HEADERS = { 'x-api-key': API_KEY };
const ROUTES = {
LEVANT: {
from: [35.9106, 31.9539], // Amman
to: [36.2765, 33.5138], // Damascus
center: [36.09, 32.73],
zoom: 8,
color: '#c0a048'
},
EGYPT: {
from: [31.2427, 30.0931], // Shoubra
to: [31.2201, 30.0619], // Zamalek
center: [31.23, 30.07],
zoom: 13,
color: '#2563eb'
}
};
/* ───────────────────────────────────────────
MAP INIT
─────────────────────────────────────────── */
maplibregl.setRTLTextPlugin(
'https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.2.3/mapbox-gl-rtl-text.js',
null, true
);
const map = new maplibregl.Map({
container: 'map',
center: ROUTES.LEVANT.center,
zoom: ROUTES.LEVANT.zoom,
style: './style.json', // ← style.json fixed (no duplicate IDs)
pitch: 0,
bearing: 0,
attributionControl: false
});
map.addControl(new maplibregl.NavigationControl(), 'bottom-right');
/* ───────────────────────────────────────────
MAP LOAD — count DB names shown on map
─────────────────────────────────────────── */
map.on('load', () => {
console.log('[Intaleq] Map loaded — style v3.1.0');
loadDBNamesStatus();
});
/*
* Fetch the same GeoJSON the style uses for intaleq_dynamic_pois
* and report how many names were loaded on the map.
*/
async function loadDBNamesStatus() {
const statusEl = document.getElementById('db-status-text');
try {
const res = await fetch(`${API_BASE}/api/geocoding/geojson`, { headers: HEADERS });
const data = await res.json();
const count = data?.features?.length ?? 0;
statusEl.textContent = count > 0
? `✅ ${count} اسم مُحمَّل من قاعدة البيانات على الخريطة`
: '⚠️ لا توجد أسماء في قاعدة البيانات حتى الآن';
// Refresh the source in case MapLibre cached an empty response
if (count > 0 && map.getSource('intaleq_dynamic_pois')) {
map.getSource('intaleq_dynamic_pois').setData(data);
}
} catch (err) {
console.warn('[Intaleq] DB names fetch failed:', err);
statusEl.textContent = '⚠️ تعذّر الاتصال بقاعدة البيانات';
}
}
/* ───────────────────────────────────────────
POLYLINE DECODER
─────────────────────────────────────────── */
function decodePoly(str) {
let i = 0, lat = 0, lng = 0, out = [];
while (i < str.length) {
let byte, shift = 0, result = 0;
do { byte = str.charCodeAt(i++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20);
lat += (result & 1) ? ~(result >> 1) : (result >> 1);
shift = 0; result = 0;
do { byte = str.charCodeAt(i++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20);
lng += (result & 1) ? ~(result >> 1) : (result >> 1);
out.push([lng / 1e5, lat / 1e5]);
}
return out;
}
/* ───────────────────────────────────────────
ROUTING
─────────────────────────────────────────── */
async function handleRoute(region) {
const cfg = ROUTES[region];
const loading = document.getElementById('loading');
const status = document.getElementById('status');
loading.style.display = 'flex';
status.style.display = 'none';
try {
const url = `${API_BASE}/api/maps/route`
+ `?fromLat=${cfg.from[1]}&fromLng=${cfg.from[0]}`
+ `&toLat=${cfg.to[1]}&toLng=${cfg.to[0]}`;
const res = await fetch(url, { headers: HEADERS });
const data = await res.json();
if (data.statusCode >= 400) throw new Error(data.message || 'Routing error');
const coords = typeof data.points === 'string'
? decodePoly(data.points)
: data.points;
if (!coords?.length) throw new Error('No route points returned');
// Add or update route layer
const geojson = { type: 'Feature', geometry: { type: 'LineString', coordinates: coords } };
if (map.getSource('route')) {
map.getSource('route').setData(geojson);
map.setPaintProperty('route-line', 'line-color', cfg.color);
} else {
map.addSource('route', { type: 'geojson', data: geojson });
map.addLayer({
id: 'route-line', type: 'line', source: 'route',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': cfg.color, 'line-width': 7, 'line-opacity': 0.88 }
});
}
// Fit map to route
const bounds = coords.reduce(
(b, c) => b.extend(c),
new maplibregl.LngLatBounds(coords[0], coords[0])
);
map.fitBounds(bounds, { padding: 110, duration: 1800 });
// Show summary
setTimeout(() => {
status.style.display = 'block';
status.innerHTML = `
<div style="font-weight:700;color:var(--primary-dark);margin-bottom:8px">🏁 ملخص الرحلة</div>
<div class="status-row"><span>المسافة:</span><span>${(data.distance / 1000).toFixed(1)} كم</span></div>
<div class="status-row"><span>الوقت المتوقع:</span><span>${Math.round(data.duration / 60)} دقيقة</span></div>
`;
}, 600);
} catch (err) {
console.error('[Intaleq] Route error:', err);
status.style.display = 'block';
status.innerHTML = `<span style="color:#c0392b">⚠️ تعذّر تحميل المسار: ${err.message}</span>`;
} finally {
loading.style.display = 'none';
}
}
/* ───────────────────────────────────────────
SEARCH
─────────────────────────────────────────── */
let searchMarkers = [];
async function performSearch() {
const q = document.getElementById('search-input').value.trim();
const resultsEl = document.getElementById('search-results');
if (!q) return;
const center = map.getCenter();
const url = `${API_BASE}/api/geocoding/search`
+ `?q=${encodeURIComponent(q)}`
+ `&lat=${center.lat}&lng=${center.lng}&radius=20000`;
resultsEl.style.display = 'block';
resultsEl.innerHTML = '<div class="result-item"><span class="meta">جاري البحث...</span></div>';
// Clear old markers
searchMarkers.forEach(m => m.remove());
searchMarkers = [];
try {
const res = await fetch(url, { headers: HEADERS });
const data = await res.json();
if (!data.results?.length) {
resultsEl.innerHTML = '<div class="result-item"><span class="meta">لا توجد نتائج.</span></div>';
return;
}
resultsEl.innerHTML = '';
data.results.forEach(place => {
const lng = parseFloat(place.longitude);
const lat = parseFloat(place.latitude);
const name = place.name_ar || place.name;
const dist = place.distance ? `${(place.distance / 1000).toFixed(2)} كم` : '';
// Result row
const item = document.createElement('div');
item.className = 'result-item';
item.innerHTML = `
<div class="name">${name}</div>
<div class="meta">${place.category || ''} ${dist ? '• ' + dist : ''}</div>
`;
item.onclick = () => map.flyTo({ center: [lng, lat], zoom: 16 });
resultsEl.appendChild(item);
// Map marker — gold for DB entries, blue for OSM
const isDB = place.source === 'user_submitted' || place.source === 'intaleq_db';
const color = isDB ? '#c0a048' : '#2563eb';
let popupHTML = `<strong>${name}</strong><br><span style="font-size:12px;color:#888">${place.category || ''}</span>`;
if (isDB) {
popupHTML += `<hr style="margin:8px 0; border:0; border-top:1px solid #eee">
<button onclick="directDelete(${place.id})" style="background:#c0392b; color:#fff; border:0; padding:4px 8px; border-radius:4px; font-size:10px; cursor:pointer; width:100%">حذف القيد</button>`;
}
const marker = new maplibregl.Marker({ color })
.setLngLat([lng, lat])
.setPopup(new maplibregl.Popup({ offset: 25 })
.setHTML(popupHTML))
.addTo(map);
searchMarkers.push(marker);
});
} catch (err) {
console.error('[Intaleq] Search error:', err);
resultsEl.innerHTML = '<div class="result-item" style="color:#c0392b">⚠️ فشل الاتصال بخدمة البحث.</div>';
}
}
// Enter key triggers search
document.getElementById('search-input').addEventListener('keydown', e => {
if (e.key === 'Enter') performSearch();
});
/* ───────────────────────────────────────────
INTERACTIVE MANAGEMENT
─────────────────────────────────────────── */
let lastRightClick = null;
map.on('contextmenu', (e) => {
lastRightClick = e.lngLat;
const menu = document.getElementById('context-menu');
menu.style.display = 'block';
menu.style.left = e.point.x + 'px';
menu.style.top = e.point.y + 'px';
});
document.addEventListener('click', () => {
document.getElementById('context-menu').style.display = 'none';
});
function copyCoords() {
if (!lastRightClick) return;
const txt = `${lastRightClick.lat.toFixed(7)}, ${lastRightClick.lng.toFixed(7)}`;
navigator.clipboard.writeText(txt);
alert('تم نسخ الإحداثيات: ' + txt);
}
async function deleteFromContext() {
if (!lastRightClick) return;
const country = prompt('يرجى كتابة الدولة للتأكيد (syria, jordan, egypt):', 'syria');
if (!country) return;
const name = prompt('يرجى كتابة اسم الموقع لحذفه نهائياً:');
if (!name) return;
await apiDelete(name, country);
}
function toggleAdminPanel() {
const p = document.getElementById('admin-panel');
p.style.display = p.style.display === 'block' ? 'none' : 'block';
}
async function handleAdminDelete() {
const name = document.getElementById('admin-delete-name').value.trim();
const country = document.getElementById('admin-delete-country').value;
if (!name) return alert('يرجى إدخال الاسم');
if (!confirm(`هل أنت متأكد من حذف 모든 المواقع المسمى "${name}" في ${country}؟`)) return;
await apiDelete(name, country);
}
async function directDelete(id) {
const country = prompt('يرجى تحديد الدولة (syria, jordan, egypt):', 'syria');
if (!country) return;
if (!confirm('هل أنت متأكد من حذف هذا القيد؟')) return;
try {
const res = await fetch(`${API_BASE}/api/geocoding/places?id=${id}&country=${country}`, {
method: 'DELETE',
headers: HEADERS
});
const data = await res.json();
if (data.success) {
alert('تم الحذف بنجاح');
location.reload();
}
} catch (err) {
alert('فشل الحذف');
}
}
async function apiDelete(name, country) {
const info = document.getElementById('admin-info');
info.textContent = 'جاري الحذف...';
try {
const res = await fetch(`${API_BASE}/api/geocoding/places?name=${encodeURIComponent(name)}&country=${country}`, {
method: 'DELETE',
headers: HEADERS
});
const data = await res.json();
if (data.success) {
info.textContent = `✅ تم حذف ${data.affected} قيد بنجاح.`;
setTimeout(() => location.reload(), 2000);
} else {
info.textContent = '❌ فشل الحذف.';
}
} catch (err) {
info.textContent = '❌ خطأ في الاتصال بالسيرفر.';
}
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+547 -178
View File
@@ -12,14 +12,15 @@
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;800&family=Noto+Sans+Arabic:wght@400;700&display=swap');
:root {
--primary: #c0a048; /* Warm primary from style v3 */
--primary-dark: #b89038;
--accent: #2563eb;
--bg-glass: rgba(255, 255, 255, 0.85);
--shadow: 0 12px 40px rgba(0,0,0,0.12);
--primary: #c0a048;
--primary-dark: #8a6e20;
--accent: #2563eb;
--bg-glass: rgba(255, 255, 255, 0.88);
--shadow: 0 12px 40px rgba(0,0,0,0.12);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Outfit', 'Noto Sans Arabic', sans-serif;
background: #F2EDE4;
@@ -27,260 +28,628 @@
color: #342C20;
}
#map { width: 100%; height: 100vh; position: absolute; top: 0; left: 0; }
.controls {
position: absolute; top: 24px; right: 24px; z-index: 100;
background: var(--bg-glass);
backdrop-filter: blur(12px) saturate(180%);
-webkit-backdrop-filter: blur(12px) saturate(180%);
border-radius: 24px; padding: 28px; width: 380px;
box-shadow: var(--shadow);
border: 1px solid rgba(255, 255, 255, 0.3);
transition: all 0.3s ease;
}
.branding {
position: absolute; bottom: 32px; left: 32px; z-index: 100;
background: var(--bg-glass);
backdrop-filter: blur(8px);
padding: 10px 20px; border-radius: 12px;
display: flex; align-items: center; gap: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.1);
border: 1px solid rgba(255, 255, 255, 0.4);
}
.badge {
display: inline-block; padding: 4px 10px; border-radius: 6px;
font-size: 11px; font-weight: 800; background: var(--primary); color: white;
margin-bottom: 12px; text-transform: uppercase; letter-spacing: 1px;
}
h2 { font-size: 22px; font-weight: 800; margin-bottom: 8px; color: #18100A; }
p { font-size: 14px; color: #584840; margin-bottom: 24px; line-height: 1.5; }
.btn-group { display: flex; flex-direction: column; gap: 12px; }
.btn {
position: relative; padding: 16px 20px; border: none; border-radius: 16px;
font-size: 15px; font-weight: 700; cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex; align-items: center; justify-content: space-between;
overflow: hidden;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
color: white;
box-shadow: 0 4px 15px rgba(192, 160, 72, 0.3);
}
.btn-primary:hover {
transform: translateY(-3px);
box-shadow: 0 8px 25px rgba(192, 160, 72, 0.4);
}
.btn-secondary {
background: white;
color: #342C20;
border: 1px solid rgba(0,0,0,0.05);
box-shadow: 0 4px 10px rgba(0,0,0,0.03);
}
.btn-secondary:hover {
background: #fdfaf5;
transform: translateY(-2px);
box-shadow: 0 6px 15px rgba(0,0,0,0.05);
}
.btn span.icon { font-size: 18px; }
.info-box {
background: rgba(253, 250, 245, 0.6);
border-radius: 16px; padding: 18px; font-size: 14px;
color: #342C20; margin-top: 24px;
border: 1px dashed var(--primary);
line-height: 1.6; display: none;
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
/* ── MAP ── */
#map { width: 100vw; height: 100vh; position: absolute; inset: 0; }
/* ── LOADING OVERLAY ── */
#loading {
display: none; position: fixed; inset: 0;
background: rgba(242, 237, 228, 0.6);
background: rgba(242,237,228,0.6);
z-index: 2000; align-items: center; justify-content: center;
backdrop-filter: blur(6px);
}
.spinner {
width: 40px; height: 40px; border: 4px solid rgba(192, 160, 72, 0.1);
border-top: 4px solid var(--primary); border-radius: 50%;
animation: spin 1s linear infinite;
width: 40px; height: 40px;
border: 4px solid rgba(192,160,72,0.15);
border-top: 4px solid var(--primary);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
@keyframes spin { to { transform: rotate(360deg); } }
/* Custom Scrollbar for better look */
::-webkit-scrollbar { width: 6px; }
/* ── CONTROLS PANEL ── */
.controls {
position: absolute; top: 24px; right: 24px; z-index: 100;
background: var(--bg-glass);
backdrop-filter: blur(14px) saturate(180%);
-webkit-backdrop-filter: blur(14px) saturate(180%);
border-radius: 24px; padding: 28px; width: 380px;
box-shadow: var(--shadow);
border: 1px solid rgba(255,255,255,0.35);
}
.badge {
display: inline-block; padding: 4px 10px; border-radius: 6px;
font-size: 11px; font-weight: 800;
background: var(--primary); color: #fff;
margin-bottom: 12px; text-transform: uppercase; letter-spacing: 1px;
}
.panel-title { font-size: 22px; font-weight: 800; margin-bottom: 6px; color: #18100A; }
.panel-desc { font-size: 13px; color: #584840; margin-bottom: 20px; line-height: 1.55; }
/* ── SEARCH ── */
.search-wrap { position: relative; margin-bottom: 20px; }
.search-wrap input {
width: 100%; padding: 13px 60px 13px 16px;
border-radius: 12px; border: 1.5px solid #ddd;
font-family: inherit; font-size: 14px;
outline: none; transition: border-color 0.25s;
background: rgba(255,255,255,0.7);
}
.search-wrap input:focus { border-color: var(--primary); }
.search-wrap .search-btn {
position: absolute; left: 8px; top: 50%; transform: translateY(-50%);
background: var(--primary); color: #fff;
border: none; padding: 6px 13px; border-radius: 8px;
cursor: pointer; font-size: 12px; font-weight: 700;
transition: background 0.2s;
}
.search-wrap .search-btn:hover { background: var(--primary-dark); }
#search-results {
display: none; max-height: 220px; overflow-y: auto;
background: #fff; border-radius: 12px;
border: 1px solid #eee;
box-shadow: 0 4px 14px rgba(0,0,0,0.06);
margin-top: 8px;
}
.result-item {
padding: 11px 16px; border-bottom: 1px solid #f5f5f5;
cursor: pointer; transition: background 0.15s;
}
.result-item:last-child { border-bottom: none; }
.result-item:hover { background: #fdfaf5; }
.result-item .name { font-weight: 700; font-size: 13px; }
.result-item .meta { font-size: 11px; color: #999; margin-top: 2px; }
/* ── ROUTE BUTTONS ── */
.btn-group { display: flex; flex-direction: column; gap: 10px; }
.btn {
padding: 15px 18px; border: none; border-radius: 14px;
font-size: 14px; font-weight: 700; cursor: pointer;
display: flex; align-items: center; justify-content: space-between;
transition: transform 0.25s, box-shadow 0.25s;
font-family: inherit;
}
.btn .label-sub { font-size: 11px; font-weight: 400; opacity: 0.75; margin-bottom: 2px; }
.btn .icon { font-size: 20px; }
.btn-gold {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
color: #fff;
box-shadow: 0 4px 16px rgba(192,160,72,0.28);
}
.btn-gold:hover { transform: translateY(-2px); box-shadow: 0 8px 22px rgba(192,160,72,0.38); }
.btn-white {
background: #fff; color: #342C20;
border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 3px 8px rgba(0,0,0,0.04);
}
.btn-white:hover { background: #fdfaf5; transform: translateY(-2px); }
/* ── STATUS BOX ── */
#status {
display: none; margin-top: 20px;
background: rgba(253,250,245,0.7);
border: 1.5px dashed var(--primary);
border-radius: 14px; padding: 16px; font-size: 13px;
line-height: 1.65;
animation: fadeUp 0.35s ease-out;
}
.status-row {
display: flex; justify-content: space-between; margin-top: 6px;
}
.status-row span:last-child { font-weight: 800; }
/* ── DB NAMES INDICATOR ── */
#db-status {
margin-top: 14px; padding: 10px 14px;
background: rgba(192,160,72,0.08);
border-radius: 10px; font-size: 12px; color: #7A5C10;
display: flex; align-items: center; gap: 8px;
}
.dot-gold {
width: 8px; height: 8px; border-radius: 50%;
background: var(--primary); flex-shrink: 0;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── BRANDING ── */
.branding {
position: absolute; bottom: 28px; left: 28px; z-index: 100;
background: var(--bg-glass); backdrop-filter: blur(8px);
padding: 10px 18px; border-radius: 12px;
display: flex; align-items: center; gap: 10px;
box-shadow: 0 6px 20px rgba(0,0,0,0.09);
border: 1px solid rgba(255,255,255,0.4);
}
.branding span { font-size: 12px; font-weight: 800; color: #18100A; letter-spacing: 0.5px; }
/* ── CONTEXT MENU ── */
#context-menu {
display: none; position: fixed; z-index: 3000;
background: var(--bg-glass); backdrop-filter: blur(12px);
border-radius: 12px; min-width: 180px;
box-shadow: 0 8px 30px rgba(0,0,0,0.15);
border: 1px solid rgba(255,255,255,0.4);
padding: 6px;
}
.menu-item {
padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 700;
cursor: pointer; display: flex; align-items: center; gap: 10px;
transition: background 0.2s;
}
.menu-item:hover { background: rgba(192,160,72,0.12); color: var(--primary-dark); }
.menu-item .icon { font-size: 16px; }
/* ── ADMIN PANEL ── */
#admin-panel {
position: absolute; bottom: 84px; right: 24px; z-index: 100;
background: var(--bg-glass); backdrop-filter: blur(14px);
border-radius: 20px; padding: 20px; width: 340px;
box-shadow: var(--shadow); border: 1px solid rgba(255,255,255,0.3);
display: none; animation: fadeUp 0.3s;
}
.admin-title { font-size: 16px; font-weight: 800; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
.admin-close { cursor: pointer; opacity: 0.5; margin-right: auto; font-size: 18px; }
.delete-group { margin-top: 15px; }
.delete-group label { display: block; font-size: 11px; font-weight: 700; margin-bottom: 6px; opacity: 0.7; }
.delete-group input, .delete-group select {
width: 100%; padding: 10px; border-radius: 8px; border: 1.5px solid #eee;
font-size: 13px; margin-bottom: 10px; outline: none;
}
.delete-group input:focus { border-color: #c0392b; }
.btn-delete {
width: 100%; padding: 10px; background: #c0392b; color: #fff; border: none;
border-radius: 10px; font-weight: 800; cursor: pointer;
}
#toggle-admin {
position: absolute; top: 110px; left: 10px; z-index: 100;
background: var(--bg-glass); border: none; border-radius: 50%;
width: 44px; height: 44px; display: flex; align-items: center; justify-content: center;
box-shadow: var(--shadow); cursor: pointer; font-size: 20px;
}
/* ── SCROLLBAR ── */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--primary); border-radius: 10px; }
</style>
</head>
<body>
<!-- MAP -->
<div id="map"></div>
<!-- LOADING -->
<div id="loading">
<div style="text-align: center;">
<div class="spinner" style="margin: 0 auto 15px;"></div>
<strong style="color: var(--primary-dark);">جاري حساب المسار الذكي...</strong>
<div style="text-align:center">
<div class="spinner" style="margin: 0 auto 14px;"></div>
<strong style="color:var(--primary-dark)">جاري حساب المسار الذكي...</strong>
</div>
</div>
<!-- BRANDING -->
<div class="branding">
<img src="/intaleq-logo.png" alt="Logo" style="height: 24px;" onerror="this.style.display='none'">
<span style="font-size: 13px; font-weight: 800; color: #18100A; letter-spacing: 0.5px;">INTALEQ PREMIUM MAPS</span>
<img src="/intaleq-logo.png" alt="Logo" style="height:22px" onerror="this.style.display='none'">
<span>INTALEQ PREMIUM MAPS</span>
</div>
<!-- CONTROLS -->
<div class="controls">
<div class="badge">Version 3.0.0</div>
<h2>🗺️ خرائط انطلاقة الذكية</h2>
<p>حلول جغرافية متقدمة لمنطقة الشرق الأوسط وشمال أفريقيا بدقة متناهية ونظام عرض ثلاثي الأبعاد.</p>
<div class="badge">v3.1.0</div>
<div class="panel-title">🗺️ خرائط انطلاقة الذكية</div>
<p class="panel-desc">حلول جغرافية متقدمة لمنطقة الشرق الأوسط وشمال أفريقيا — عرض ثلاثي الأبعاد وأسماء مباشرة من قاعدة البيانات.</p>
<!-- SEARCH -->
<div class="search-wrap">
<input id="search-input" type="text" placeholder="ابحث عن مكان... (مسجد، مستشفى، ...)" />
<button class="search-btn" onclick="performSearch()">بحث</button>
<div id="search-results"></div>
</div>
<!-- ROUTE BUTTONS -->
<div class="btn-group">
<button class="btn btn-primary" onclick="handleRoute('LEVANT')">
<div style="text-align: right;">
<div style="font-size: 12px; opacity: 0.8; margin-bottom: 2px;">المسار المقترح - سوريا</div>
<span>عمان ➔ دمشق</span>
<button class="btn btn-gold" onclick="handleRoute('LEVANT')">
<div>
<div class="label-sub">مسار مقترح — سوريا</div>
<span>عمّان ← دمشق</span>
</div>
<span class="icon">🚀</span>
</button>
<button class="btn btn-secondary" onclick="handleRoute('EGYPT')">
<div style="text-align: right;">
<div style="font-size: 12px; opacity: 0.7; margin-bottom: 2px;">المسار المقترح - مصر</div>
<span>شبرا ➔ الزمالك</span>
<button class="btn btn-white" onclick="handleRoute('EGYPT')">
<div>
<div class="label-sub">مسار مقترح — مصر</div>
<span>شبرا ← الزمالك</span>
</div>
<span class="icon">🇪🇬</span>
</button>
</div>
<div id="status" class="info-box"></div>
<!-- STATUS -->
<div id="status"></div>
<!-- DB NAMES INDICATOR -->
<div id="db-status">
<div class="dot-gold"></div>
<span id="db-status-text">جاري تحميل الأسماء من قاعدة البيانات...</span>
</div>
</div>
<!-- TOGGLE ADMIN -->
<button id="toggle-admin" onclick="toggleAdminPanel()" title="إدارة البيانات">⚙️</button>
<!-- CONTEXT MENU -->
<div id="context-menu">
<div class="menu-item" onclick="copyCoords()">
<span class="icon">📋</span> <span>نسخ الإحداثيات</span>
</div>
<div class="menu-item" id="menu-delete-btn" style="color:#c0392b" onclick="deleteFromContext()">
<span class="icon">🗑️</span> <span>حذف هذا الموقع</span>
</div>
</div>
<!-- ADMIN PANEL -->
<div id="admin-panel">
<div class="admin-title">
<span>🛡️ إدارة البيانات</span>
<span class="admin-close" onclick="toggleAdminPanel()">×</span>
</div>
<div class="delete-group">
<label>حذف موقع بواسطة الاسم</label>
<input type="text" id="admin-delete-name" placeholder="أدخل اسم الموقع..." />
<label>الدولة</label>
<select id="admin-delete-country">
<option value="syria">سوريا</option>
<option value="jordan">الأردن</option>
<option value="egypt">مصر</option>
</select>
<button class="btn-delete" onclick="handleAdminDelete()">حذف الموقع نهائياً</button>
</div>
<div id="admin-info" style="font-size:11px; margin-top:10px; opacity:0.6; text-align:center"></div>
</div>
<script>
const TILES_URL = 'https://tiles.intaleqapp.com';
const API_KEY = 'intaleq_secret_2026';
/* ───────────────────────────────────────────
CONFIG
─────────────────────────────────────────── */
const API_BASE = 'https://map-saas.intaleqapp.com';
const API_KEY = 'intaleq_secret_2026';
const HEADERS = { 'x-api-key': API_KEY };
const COORDS = {
const ROUTES = {
LEVANT: {
from: [35.9106, 31.9539], // Amman
to: [36.2765, 33.5138], // Damascus
from: [35.9106, 31.9539], // Amman
to: [36.2765, 33.5138], // Damascus
center: [36.09, 32.73],
zoom: 8
zoom: 8,
color: '#c0a048'
},
EGYPT: {
from: [31.2427, 30.0931], // Shoubra
to: [31.2201, 30.0619], // Zamalek
from: [31.2427, 30.0931], // Shoubra
to: [31.2201, 30.0619], // Zamalek
center: [31.23, 30.07],
zoom: 13
zoom: 13,
color: '#2563eb'
}
};
maplibregl.setRTLTextPlugin('https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.2.3/mapbox-gl-rtl-text.js', null, true);
/* ───────────────────────────────────────────
MAP INIT
─────────────────────────────────────────── */
maplibregl.setRTLTextPlugin(
'https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.2.3/mapbox-gl-rtl-text.js',
null, true
);
const map = new maplibregl.Map({
container: 'map',
center: COORDS.LEVANT.center,
zoom: COORDS.LEVANT.zoom,
attributionControl: false,
style: './style.json?v=' + Date.now(),
pitch: 45,
bearing: -10
center: ROUTES.LEVANT.center,
zoom: ROUTES.LEVANT.zoom,
style: './style.json', // ← style.json fixed (no duplicate IDs)
pitch: 0,
bearing: 0,
attributionControl: false
});
let mapLoaded = false;
map.addControl(new maplibregl.NavigationControl(), 'bottom-right');
/* ───────────────────────────────────────────
MAP LOAD — count DB names shown on map
─────────────────────────────────────────── */
map.on('load', () => {
mapLoaded = true;
console.log('Intaleq Style v3 Loaded');
console.log('[Intaleq] Map loaded — style v3.1.0');
loadDBNamesStatus();
});
function decodePoly(str) {
let index = 0, lat = 0, lng = 0, coordinates = [];
while (index < str.length) {
let byte, shift = 0, result = 0;
do { byte = str.charCodeAt(index++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20);
lat += ((result & 1) ? ~(result >> 1) : (result >> 1));
shift = 0; result = 0;
do { byte = str.charCodeAt(index++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20);
lng += ((result & 1) ? ~(result >> 1) : (result >> 1));
coordinates.push([lng / 1e5, lat / 1e5]);
/*
* Fetch the same GeoJSON the style uses for intaleq_dynamic_pois
* and report how many names were loaded on the map.
*/
async function loadDBNamesStatus() {
const statusEl = document.getElementById('db-status-text');
try {
const res = await fetch(`${API_BASE}/api/geocoding/geojson`, { headers: HEADERS });
const data = await res.json();
const count = data?.features?.length ?? 0;
statusEl.textContent = count > 0
? `✅ ${count} اسم مُحمَّل من قاعدة البيانات على الخريطة`
: '⚠️ لا توجد أسماء في قاعدة البيانات حتى الآن';
// Refresh the source in case MapLibre cached an empty response
if (count > 0 && map.getSource('intaleq_dynamic_pois')) {
map.getSource('intaleq_dynamic_pois').setData(data);
}
} catch (err) {
console.warn('[Intaleq] DB names fetch failed:', err);
statusEl.textContent = '⚠️ تعذّر الاتصال بقاعدة البيانات';
}
return coordinates;
}
/* ───────────────────────────────────────────
POLYLINE DECODER
─────────────────────────────────────────── */
function decodePoly(str) {
let i = 0, lat = 0, lng = 0, out = [];
while (i < str.length) {
let byte, shift = 0, result = 0;
do { byte = str.charCodeAt(i++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20);
lat += (result & 1) ? ~(result >> 1) : (result >> 1);
shift = 0; result = 0;
do { byte = str.charCodeAt(i++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20);
lng += (result & 1) ? ~(result >> 1) : (result >> 1);
out.push([lng / 1e5, lat / 1e5]);
}
return out;
}
/* ───────────────────────────────────────────
ROUTING
─────────────────────────────────────────── */
async function handleRoute(region) {
if (!mapLoaded) return;
const cfg = ROUTES[region];
const loading = document.getElementById('loading');
const status = document.getElementById('status');
const config = COORDS[region];
const l = document.getElementById('loading');
const s = document.getElementById('status');
l.style.display = 'flex';
s.style.display = 'none';
loading.style.display = 'flex';
status.style.display = 'none';
try {
const url = `https://map-saas.intaleqapp.com/api/maps/route?fromLat=${config.from[1]}&fromLng=${config.from[0]}&toLat=${config.to[1]}&toLng=${config.to[0]}`;
const res = await fetch(url, { headers: { 'x-api-key': API_KEY } });
const url = `${API_BASE}/api/maps/route`
+ `?fromLat=${cfg.from[1]}&fromLng=${cfg.from[0]}`
+ `&toLat=${cfg.to[1]}&toLng=${cfg.to[0]}`;
const res = await fetch(url, { headers: HEADERS });
const data = await res.json();
if (data.statusCode >= 400) throw new Error(data.message || 'Routing API Error');
if (data.statusCode >= 400) throw new Error(data.message || 'Routing error');
let coords = typeof data.points === 'string' ? decodePoly(data.points) : data.points;
if (!coords || coords.length === 0) throw new Error('No valid route points found');
const coords = typeof data.points === 'string'
? decodePoly(data.points)
: data.points;
if (!coords?.length) throw new Error('No route points returned');
// Add or update route layer
const geojson = { type: 'Feature', geometry: { type: 'LineString', coordinates: coords } };
// Add/Update Route Source
if (map.getSource('route')) {
map.getSource('route').setData({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords } });
map.getSource('route').setData(geojson);
map.setPaintProperty('route-line', 'line-color', cfg.color);
} else {
map.addSource('route', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates: coords } } });
map.addSource('route', { type: 'geojson', data: geojson });
map.addLayer({
id: 'route-line',
type: 'line',
source: 'route',
id: 'route-line', type: 'line', source: 'route',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': region === 'EGYPT' ? '#2563eb' : '#c0a048',
'line-width': 8,
'line-opacity': 0.85
}
paint: { 'line-color': cfg.color, 'line-width': 7, 'line-opacity': 0.88 }
});
}
// Fit bounds
const bounds = coords.reduce((b, c) => b.extend(c), new maplibregl.LngLatBounds(coords[0], coords[0]));
map.fitBounds(bounds, { padding: 120, duration: 2000 });
// Fit map to route
const bounds = coords.reduce(
(b, c) => b.extend(c),
new maplibregl.LngLatBounds(coords[0], coords[0])
);
map.fitBounds(bounds, { padding: 110, duration: 1800 });
// Show summary
setTimeout(() => {
s.style.display = 'block';
s.innerHTML = `
<div style="font-weight: 700; margin-bottom: 8px; color: var(--primary-dark);">🏁 ملخص الرحلة الذكي</div>
<div style="display: flex; justify-content: space-between;">
<span>المسافة الفاصلة:</span>
<span style="font-weight: 800;">${(data.distance/1000).toFixed(1)} كم</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span>الوقت المتوقع:</span>
<span style="font-weight: 800;">${(data.duration/60).toFixed(0)} دقيقة</span>
</div>
status.style.display = 'block';
status.innerHTML = `
<div style="font-weight:700;color:var(--primary-dark);margin-bottom:8px">🏁 ملخص الرحلة</div>
<div class="status-row"><span>المسافة:</span><span>${(data.distance / 1000).toFixed(1)} كم</span></div>
<div class="status-row"><span>الوقت المتوقع:</span><span>${Math.round(data.duration / 60)} دقيقة</span></div>
`;
}, 500);
}, 600);
} catch (e) {
console.error(e);
alert('عذراً، فشل في حساب المسار: ' + e.message);
} catch (err) {
console.error('[Intaleq] Route error:', err);
status.style.display = 'block';
status.innerHTML = `<span style="color:#c0392b">⚠️ تعذّر تحميل المسار: ${err.message}</span>`;
} finally {
l.style.display = 'none';
loading.style.display = 'none';
}
}
/* ───────────────────────────────────────────
SEARCH
─────────────────────────────────────────── */
let searchMarkers = [];
async function performSearch() {
const q = document.getElementById('search-input').value.trim();
const resultsEl = document.getElementById('search-results');
if (!q) return;
const center = map.getCenter();
const url = `${API_BASE}/api/geocoding/search`
+ `?q=${encodeURIComponent(q)}`
+ `&lat=${center.lat}&lng=${center.lng}&radius=20000`;
resultsEl.style.display = 'block';
resultsEl.innerHTML = '<div class="result-item"><span class="meta">جاري البحث...</span></div>';
// Clear old markers
searchMarkers.forEach(m => m.remove());
searchMarkers = [];
try {
const res = await fetch(url, { headers: HEADERS });
const data = await res.json();
if (!data.results?.length) {
resultsEl.innerHTML = '<div class="result-item"><span class="meta">لا توجد نتائج.</span></div>';
return;
}
resultsEl.innerHTML = '';
data.results.forEach(place => {
const lng = parseFloat(place.longitude);
const lat = parseFloat(place.latitude);
const name = place.name_ar || place.name;
const dist = place.distance ? `${(place.distance / 1000).toFixed(2)} كم` : '';
// Result row
const item = document.createElement('div');
item.className = 'result-item';
item.innerHTML = `
<div class="name">${name}</div>
<div class="meta">${place.category || ''} ${dist ? '• ' + dist : ''}</div>
`;
item.onclick = () => map.flyTo({ center: [lng, lat], zoom: 16 });
resultsEl.appendChild(item);
// Map marker — gold for DB entries, blue for OSM
const isDB = place.source === 'user_submitted' || place.source === 'intaleq_db';
const color = isDB ? '#c0a048' : '#2563eb';
let popupHTML = `<strong>${name}</strong><br><span style="font-size:12px;color:#888">${place.category || ''}</span>`;
if (isDB) {
popupHTML += `<hr style="margin:8px 0; border:0; border-top:1px solid #eee">
<button onclick="directDelete(${place.id})" style="background:#c0392b; color:#fff; border:0; padding:4px 8px; border-radius:4px; font-size:10px; cursor:pointer; width:100%">حذف القيد</button>`;
}
const marker = new maplibregl.Marker({ color })
.setLngLat([lng, lat])
.setPopup(new maplibregl.Popup({ offset: 25 })
.setHTML(popupHTML))
.addTo(map);
searchMarkers.push(marker);
});
} catch (err) {
console.error('[Intaleq] Search error:', err);
resultsEl.innerHTML = '<div class="result-item" style="color:#c0392b">⚠️ فشل الاتصال بخدمة البحث.</div>';
}
}
// Enter key triggers search
document.getElementById('search-input').addEventListener('keydown', e => {
if (e.key === 'Enter') performSearch();
});
/* ───────────────────────────────────────────
INTERACTIVE MANAGEMENT
─────────────────────────────────────────── */
let lastRightClick = null;
map.on('contextmenu', (e) => {
lastRightClick = e.lngLat;
const menu = document.getElementById('context-menu');
menu.style.display = 'block';
menu.style.left = e.point.x + 'px';
menu.style.top = e.point.y + 'px';
});
document.addEventListener('click', () => {
document.getElementById('context-menu').style.display = 'none';
});
function copyCoords() {
if (!lastRightClick) return;
const txt = `${lastRightClick.lat.toFixed(7)}, ${lastRightClick.lng.toFixed(7)}`;
navigator.clipboard.writeText(txt);
alert('تم نسخ الإحداثيات: ' + txt);
}
async function deleteFromContext() {
if (!lastRightClick) return;
const country = prompt('يرجى كتابة الدولة للتأكيد (syria, jordan, egypt):', 'syria');
if (!country) return;
const name = prompt('يرجى كتابة اسم الموقع لحذفه نهائياً:');
if (!name) return;
await apiDelete(name, country);
}
function toggleAdminPanel() {
const p = document.getElementById('admin-panel');
p.style.display = p.style.display === 'block' ? 'none' : 'block';
}
async function handleAdminDelete() {
const name = document.getElementById('admin-delete-name').value.trim();
const country = document.getElementById('admin-delete-country').value;
if (!name) return alert('يرجى إدخال الاسم');
if (!confirm(`هل أنت متأكد من حذف 모든 المواقع المسمى "${name}" في ${country}؟`)) return;
await apiDelete(name, country);
}
async function directDelete(id) {
const country = prompt('يرجى تحديد الدولة (syria, jordan, egypt):', 'syria');
if (!country) return;
if (!confirm('هل أنت متأكد من حذف هذا القيد؟')) return;
try {
const res = await fetch(`${API_BASE}/api/geocoding/places?id=${id}&country=${country}`, {
method: 'DELETE',
headers: HEADERS
});
const data = await res.json();
if (data.success) {
alert('تم الحذف بنجاح');
location.reload();
}
} catch (err) {
alert('فشل الحذف');
}
}
async function apiDelete(name, country) {
const info = document.getElementById('admin-info');
info.textContent = 'جاري الحذف...';
try {
const res = await fetch(`${API_BASE}/api/geocoding/places?name=${encodeURIComponent(name)}&country=${country}`, {
method: 'DELETE',
headers: HEADERS
});
const data = await res.json();
if (data.success) {
info.textContent = `✅ تم حذف ${data.affected} قيد بنجاح.`;
setTimeout(() => location.reload(), 2000);
} else {
info.textContent = '❌ فشل الحذف.';
}
} catch (err) {
info.textContent = '❌ خطأ في الاتصال بالسيرفر.';
}
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+191 -366
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -43,6 +43,12 @@ services:
platform: linux/amd64
ports:
- "8989:8080"
environment:
- JAVA_OPTS=-Xmx2g -Xms512m
deploy:
resources:
limits:
memory: 3g
volumes:
- ./infrastructure/osm-data:/data
- ./infrastructure/docker/graphhopper/config.yml:/graphhopper/config.yml
File diff suppressed because it is too large Load Diff