Final Refinement: Native Labels, Interactive Management UI, and Visual Polishing
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
// تحويل المسافة بالامتار الى درجات جغرافية تقريبية للفلترة السريعة
|
||||
const radiusInDegrees = radius / 111000;
|
||||
|
||||
for (const tableName of tables) {
|
||||
const repo = tableName === 'places_jordan' ? this.placesJordanRepository :
|
||||
tableName === 'places_egypt' ? this.placesEgyptRepository :
|
||||
this.placesSyriaRepository;
|
||||
// 1. تحديد المنطقة (الأردن، سوريا، مصر)
|
||||
let targetRegion = country?.toLowerCase();
|
||||
|
||||
if (!targetRegion && hasLocation) {
|
||||
const repo = this.getRepositoryForCoords(lat!, lon!);
|
||||
const tableName = this.getTableNameForRepo(repo);
|
||||
targetRegion = tableName.replace('places_', '');
|
||||
}
|
||||
|
||||
const userPlacesQuery = `
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
+551
-182
@@ -4,7 +4,7 @@
|
||||
<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>
|
||||
|
||||
@@ -12,275 +12,644 @@
|
||||
@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;
|
||||
overflow: hidden;
|
||||
|
||||
body {
|
||||
font-family: 'Outfit', 'Noto Sans Arabic', sans-serif;
|
||||
background: #F2EDE4;
|
||||
overflow: hidden;
|
||||
color: #342C20;
|
||||
}
|
||||
|
||||
#map { width: 100%; height: 100vh; position: absolute; top: 0; left: 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);
|
||||
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(12px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(180%);
|
||||
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.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);
|
||||
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: white;
|
||||
font-size: 11px; font-weight: 800;
|
||||
background: var(--primary); color: #fff;
|
||||
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; }
|
||||
.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; }
|
||||
|
||||
.btn-group { display: flex; flex-direction: column; gap: 12px; }
|
||||
/* ── 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 {
|
||||
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);
|
||||
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;
|
||||
overflow: hidden;
|
||||
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); }
|
||||
}
|
||||
|
||||
.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);
|
||||
/* ── 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);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 25px rgba(192, 160, 72, 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;
|
||||
}
|
||||
|
||||
.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);
|
||||
#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;
|
||||
}
|
||||
|
||||
.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); } }
|
||||
|
||||
#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.1);
|
||||
border-top: 4px solid var(--primary); border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
|
||||
/* Custom Scrollbar for better look */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
/* ── 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';
|
||||
|
||||
const COORDS = {
|
||||
/* ───────────────────────────────────────────
|
||||
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
|
||||
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 config = COORDS[region];
|
||||
const l = document.getElementById('loading');
|
||||
const s = document.getElementById('status');
|
||||
|
||||
l.style.display = 'flex';
|
||||
s.style.display = 'none';
|
||||
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 = `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');
|
||||
|
||||
let coords = typeof data.points === 'string' ? decodePoly(data.points) : data.points;
|
||||
if (!coords || coords.length === 0) throw new Error('No valid route points found');
|
||||
|
||||
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 } };
|
||||
|
||||
// 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.addLayer({
|
||||
id: 'route-line',
|
||||
type: 'line',
|
||||
source: 'route',
|
||||
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': 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 });
|
||||
|
||||
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>
|
||||
`;
|
||||
}, 500);
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('عذراً، فشل في حساب المسار: ' + e.message);
|
||||
} finally {
|
||||
l.style.display = 'none';
|
||||
// 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>
|
||||
|
||||
|
||||
+1349
-123
File diff suppressed because it is too large
Load Diff
+191
-366
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
+1586
-254
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user