feat: upgrade to Intaleq Style v3 and enable Egypt routing demo (code only)

This commit is contained in:
Hamza-Ayed
2026-03-28 21:34:21 +03:00
parent 12a206ae85
commit 014848247d
13 changed files with 2620 additions and 724 deletions
@@ -0,0 +1,45 @@
import { Column, PrimaryGeneratedColumn, CreateDateColumn, Index } from 'typeorm';
export abstract class BasePlace {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
latitude: number;
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
longitude: number;
@Column({ nullable: true })
@Index()
name: string;
@Column({ nullable: true })
@Index()
name_ar: string;
@Column({ nullable: true })
name_en: string;
@Column({ nullable: true })
address: string;
@Column({ nullable: true })
category: string;
@Column({ nullable: true })
neighbourhood: string;
@Column({ nullable: true })
city: string;
@Column({ type: 'text', nullable: true })
description: string;
@CreateDateColumn()
created_at: Date;
@Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326, nullable: true })
@Index({ spatial: true })
location: any;
}
@@ -0,0 +1,5 @@
import { Entity } from 'typeorm';
import { BasePlace } from './base-place.entity';
@Entity('places_egypt')
export class PlaceEgypt extends BasePlace {}
@@ -0,0 +1,5 @@
import { Entity } from 'typeorm';
import { BasePlace } from './base-place.entity';
@Entity('places_jordan')
export class PlaceJordan extends BasePlace {}
@@ -1,43 +1,5 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm'; import { Entity } from 'typeorm';
import { BasePlace } from './base-place.entity';
@Entity('places_syria') @Entity('places_syria')
export class PlaceSyria { export class PlaceSyria extends BasePlace {}
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
latitude: number;
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
longitude: number;
@Column({ nullable: true })
name: string;
@Column({ nullable: true })
name_ar: string;
@Column({ nullable: true })
name_en: string;
@Column({ nullable: true })
address: string;
@Column({ nullable: true })
category: string;
@Column({ nullable: true })
neighbourhood: string;
@Column({ nullable: true })
city: string;
@Column({ type: 'text', nullable: true })
description: string;
@CreateDateColumn()
created_at: Date;
@Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326, nullable: true })
location: any;
}
@@ -44,15 +44,15 @@ export class GeocodingController {
return this.geocodingService.upsertPlace(placeData); return this.geocodingService.upsertPlace(placeData);
} }
@Post('upsert-batch')
@ApiOperation({ summary: 'Add or Update multiple locations in bulk' })
async upsertBatch(@Body() body: { places: any[] }) {
return this.geocodingService.upsertBatch(body.places);
}
@Get('places') @Get('places')
@ApiOperation({ summary: 'Get recent user submitted places' }) @ApiOperation({ summary: 'Get recent user submitted places' })
async getPlaces(@Query('limit') limit?: number) { async getPlaces(@Query('limit') limit?: number) {
return this.geocodingService.getRecentPlaces(limit); return this.geocodingService.getRecentPlaces(limit);
} }
@Post('migrate')
@ApiOperation({ summary: 'Migrate legacy MySQL data to PostGIS' })
async migrate() {
return this.geocodingService.migrateFromMySQL('LEGACY_DB');
}
} }
@@ -4,6 +4,8 @@ import { GeocodingService } from './geocoding.service';
import { GeocodingInitService } from './geocoding-init.service'; import { GeocodingInitService } from './geocoding-init.service';
import { GeocodingController } from './geocoding.controller'; import { GeocodingController } from './geocoding.controller';
import { PlaceSyria } from './entities/place-syria.entity'; import { PlaceSyria } from './entities/place-syria.entity';
import { PlaceJordan } from './entities/place-jordan.entity';
import { PlaceEgypt } from './entities/place-egypt.entity';
import { OsmArea } from './entities/osm-area.entity'; import { OsmArea } from './entities/osm-area.entity';
import { OsmPointWithArea } from './entities/osm-point-with-area.entity'; import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
@@ -11,6 +13,8 @@ import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
imports: [ imports: [
TypeOrmModule.forFeature([ TypeOrmModule.forFeature([
PlaceSyria, PlaceSyria,
PlaceJordan,
PlaceEgypt,
OsmArea, OsmArea,
OsmPointWithArea OsmPointWithArea
]), ]),
+107 -162
View File
@@ -1,7 +1,10 @@
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common'; import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like } from 'typeorm'; import { Repository } from 'typeorm';
import { PlaceSyria } from './entities/place-syria.entity'; import { PlaceSyria } from './entities/place-syria.entity';
import { PlaceJordan } from './entities/place-jordan.entity';
import { PlaceEgypt } from './entities/place-egypt.entity';
import { BasePlace } from './entities/base-place.entity';
import { OsmArea } from './entities/osm-area.entity'; import { OsmArea } from './entities/osm-area.entity';
import { OsmPointWithArea } from './entities/osm-point-with-area.entity'; import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
@@ -11,221 +14,148 @@ export class GeocodingService {
constructor( constructor(
@InjectRepository(PlaceSyria) @InjectRepository(PlaceSyria)
private placesRepository: Repository<PlaceSyria>, private placesSyriaRepository: Repository<PlaceSyria>,
@InjectRepository(PlaceJordan)
private placesJordanRepository: Repository<PlaceJordan>,
@InjectRepository(PlaceEgypt)
private placesEgyptRepository: Repository<PlaceEgypt>,
@InjectRepository(OsmArea) @InjectRepository(OsmArea)
private osmAreasRepository: Repository<OsmArea>, private osmAreasRepository: Repository<OsmArea>,
@InjectRepository(OsmPointWithArea) @InjectRepository(OsmPointWithArea)
private osmPointsRepository: Repository<OsmPointWithArea>, private osmPointsRepository: Repository<OsmPointWithArea>,
) {} ) {}
/**
* تحديد المستودع المناسب بناءً على الإحداثيات الجغرافية
*/
private getRepositoryForCoords(lat: number, lng: number): Repository<BasePlace> {
if (lat >= 29 && lat <= 33.5 && lng >= 34.5 && lng <= 39.5) {
if (lat > 32.5 && lng > 35.8) return (this.placesSyriaRepository as unknown) as Repository<BasePlace>;
return (this.placesJordanRepository as unknown) as Repository<BasePlace>;
}
if (lat >= 22 && lat <= 32 && lng >= 24.5 && lng <= 37) {
return (this.placesEgyptRepository as unknown) as Repository<BasePlace>;
}
return (this.placesSyriaRepository as unknown) as Repository<BasePlace>;
}
private getTableNameForRepo(repo: Repository<BasePlace>): string {
if (repo === (this.placesJordanRepository as unknown)) return 'places_jordan';
if (repo === (this.placesEgyptRepository as unknown)) return 'places_egypt';
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) {
try { try {
if (!query || query.length < 3) return { results: [] };
// Clean query for similarity
const cleanQuery = query.trim(); const cleanQuery = query.trim();
const hasLocation = lat !== undefined && lon !== undefined; const hasLocation = lat !== undefined && lon !== undefined;
// 1. User Submitted Places // 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}%`;
for (const tableName of tables) {
const repo = tableName === 'places_jordan' ? this.placesJordanRepository :
tableName === 'places_egypt' ? this.placesEgyptRepository :
this.placesSyriaRepository;
const userPlacesQuery = ` const userPlacesQuery = `
SELECT SELECT id, name, name_ar, name_en, category, latitude, longitude, address, 'user_submitted' as source,
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, ST_SetSRID(ST_MakePoint($3, $2), 4326)) ELSE 0 END as distance,
similarity(COALESCE(name_ar, ''), $1) + similarity(COALESCE(name_en, ''), $1) as relevance similarity(COALESCE(name_ar, ''), $1) + similarity(COALESCE(name_en, ''), $1) as relevance
FROM places_syria FROM ${tableName}
WHERE (name_ar % $1 OR name_en % $1 OR name % $1 OR name_ar ILIKE $4 OR name_en ILIKE $4) WHERE (name_ar % $1 OR name_en % $1 OR name % $1 OR name_ar ILIKE $4 OR name_en ILIKE $4)
${hasLocation ? `AND location && ST_Expand(ST_SetSRID(ST_MakePoint($3, $2), 4326), $5 / 111320.0)` : ''}
${hasLocation ? `AND ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3, $2), 4326)) <= $5` : ''} ${hasLocation ? `AND ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3, $2), 4326)) <= $5` : ''}
ORDER BY distance ASC, relevance DESC ORDER BY distance ASC, relevance DESC LIMIT 10
LIMIT 10
`; `;
// 2. OSM Areas const params = [cleanQuery, lat || null, lon || null, ILikeQuery, radius];
const osmAreasQuery = ` const results = await repo.query(userPlacesQuery, params);
SELECT allResults.push(...results);
id, name, name_ar, '' as name_en, place_type as category, latitude, longitude, '' as address, 'osm_area' as source,
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3, $2), 4326)) ELSE 0 END as distance,
similarity(COALESCE(name_ar, ''), $1) + similarity(COALESCE(name, ''), $1) as relevance
FROM osm_areas
WHERE (name_ar % $1 OR name % $1 OR name_ar ILIKE $4 OR name ILIKE $4)
${hasLocation ? `AND geom && ST_Expand(ST_SetSRID(ST_MakePoint($3, $2), 4326), $5 / 111320.0)` : ''}
${hasLocation ? `AND ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3, $2), 4326)) <= $5` : ''}
ORDER BY distance ASC, relevance DESC
LIMIT 10
`;
// 3. OSM Points
const osmPointsQuery = `
SELECT
osm_id as id, name, name_ar, name_en, (COALESCE(amenity, shop, 'poi')) as category, latitude, longitude,
(COALESCE(addr_street, '') || ' ' || COALESCE(neighbourhood_name, '') || ' ' || COALESCE(city_name, '')) as address,
'osm_point' as source,
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3, $2), 4326)) ELSE 0 END as distance,
similarity(COALESCE(name_ar, ''), $1) + similarity(COALESCE(name_en, ''), $1) as relevance
FROM osm_points_with_area
WHERE (name_ar % $1 OR name_en % $1 OR name % $1 OR name_ar ILIKE $4 OR name_en ILIKE $4)
${hasLocation ? `AND geom && ST_Expand(ST_SetSRID(ST_MakePoint($3, $2), 4326), $5 / 111320.0)` : ''}
${hasLocation ? `AND ST_DistanceSphere(geom, ST_SetSRID(ST_MakePoint($3, $2), 4326)) <= $5` : ''}
ORDER BY distance ASC, relevance DESC
LIMIT 20
`;
const ILikeQuery = `%${cleanQuery}%`;
const numLat = lat !== undefined ? Number(lat) : null;
const numLon = lon !== undefined ? Number(lon) : null;
const params = [cleanQuery, numLat, numLon, ILikeQuery, radius];
const [uPlaces, oAreas, oPoints] = await Promise.all([
this.placesRepository.query(userPlacesQuery, params),
this.osmAreasRepository.query(osmAreasQuery, params),
this.osmPointsRepository.query(osmPointsQuery, params),
]);
const results = [...uPlaces, ...oAreas, ...oPoints].map(p => ({
...p,
location: { lat: p.latitude, lng: p.longitude },
distance: p.distance ? Math.round(parseFloat(p.distance)) : 0,
distance_km: p.distance ? (parseFloat(p.distance) / 1000).toFixed(2) : "0"
}));
// Re-sort combined results by distance primarily if location is given
results.sort((a, b) => {
if (hasLocation) {
if (a.distance !== b.distance) return a.distance - b.distance;
} }
return b.relevance - a.relevance;
});
return { 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);
return { results: sortedResults };
} catch (e) { } catch (e) {
this.logger.error('Search failed:', e); this.logger.error('Search failed:', e);
return { results: [] }; return { results: [] };
} }
} }
/**
* Migrate data from legacy MySQL tables to new PostGIS structure.
* دمج البيانات من MySQL إلى PostgreSQL
*/
async migrateFromMySQL(mysqlUrl: string) {
this.logger.log(`Starting migration from MySQL: ${mysqlUrl}`);
// This logic handles pulling from places_syria and osm_areas into the local Postgres
// In a real scenario, this would use a temporary secondary connection.
// For now, I provide the logic that maps all the metadata correctly.
return {
message: "Infrastructure ready for migration.",
hint: "Use the provided scp script to push your MySQL dump to the database container directly."
};
}
async reverseGeocode(lat: number, lng: number) { async reverseGeocode(lat: number, lng: number) {
try { try {
// 1. Search in user-submitted places const repo = this.getRepositoryForCoords(lat, lng);
const userPlacesQuery = ` const tableName = this.getTableNameForRepo(repo);
SELECT const query = `
id, name, name_ar, category, latitude, longitude, address, 'user_place' as source, SELECT id, name, name_ar, category, latitude, longitude, address, 'user_place' as source,
ST_DistanceSphere(location::geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326)) as distance ST_DistanceSphere(location::geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326)) as distance
FROM places_syria FROM ${tableName} WHERE location IS NOT NULL
WHERE location IS NOT NULL ORDER BY location::geometry <-> ST_SetSRID(ST_MakePoint($1, $2), 4326) ASC LIMIT 3
ORDER BY location::geometry <-> ST_SetSRID(ST_MakePoint($1, $2), 4326) ASC
LIMIT 3
`; `;
const userPlaces = await this.placesRepository.query(userPlacesQuery, [lng, lat]); return await repo.query(query, [lng, lat]);
// 2. Search in OSM areas (cities, neighbourhoods)
const osmAreasQuery = `
SELECT
id, name, name_ar, place_type as category, latitude, longitude, '' as address, 'osm_area' as source,
ST_DistanceSphere(geom::geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326)) as distance
FROM osm_areas
WHERE geom IS NOT NULL
ORDER BY geom::geometry <-> ST_SetSRID(ST_MakePoint($1, $2), 4326) ASC
LIMIT 3
`;
const osmAreas = await this.osmAreasRepository.query(osmAreasQuery, [lng, lat]);
return [...userPlaces, ...osmAreas].sort((a, b) => a.distance - b.distance);
} catch (error) { } catch (error) {
this.logger.error('Reverse geocoding error:', error); this.logger.error('Reverse geocoding error:', error);
return []; return [];
} }
} }
async addPlace(data: Partial<PlaceSyria>) { async addPlace(data: Partial<BasePlace>) {
try { try {
// 1. Basic validation const lat = Number(data.latitude);
if (!data.latitude || !data.longitude) { const lng = Number(data.longitude);
throw new HttpException('Latitude and Longitude are required', HttpStatus.BAD_REQUEST); const repo = this.getRepositoryForCoords(lat, lng);
} const tableName = this.getTableNameForRepo(repo);
// 2. Fix flipped coordinates if necessary (Damascus/Amman are Lat 31-36, Lng 35-40) const newPlace = repo.create({ ...data, latitude: lat, longitude: lng, created_at: new Date() });
// If user sends Lat > 40 and Lng < 30, they are likely flipped const savedPlace = await repo.save(newPlace);
let lat = Number(data.latitude);
let lng = Number(data.longitude);
if (lat > 35 && lng < 33) {
this.logger.warn(`Coordinates for ${data.name} seem flipped. Auto-correcting...`);
[lat, lng] = [lng, lat];
}
// 3. Create the entity await repo.query(
const newPlace = this.placesRepository.create({ `UPDATE ${tableName} SET location = ST_SetSRID(ST_MakePoint($1, $2), 4326) WHERE id = $3`,
...data,
latitude: lat,
longitude: lng,
created_at: new Date(),
});
// 4. Save and return (The 'location' will be synced either via Save or manual query)
const savedPlace = await this.placesRepository.save(newPlace);
// 5. Manually force sync the ST_Point location to ensure it's queryable immediately
await this.placesRepository.query(
`UPDATE places_syria SET location = ST_SetSRID(ST_MakePoint($1, $2), 4326) WHERE id = $3`,
[lng, lat, savedPlace.id], [lng, lat, savedPlace.id],
); );
return { ...savedPlace, latitude: lat, longitude: lng }; return { ...savedPlace, latitude: lat, longitude: lng };
} catch (error) { } catch (error) {
this.logger.error('Failed to add place:', error.message); this.logger.error('Failed to add place:', error.message);
throw new HttpException(error.message || 'Internal server error', HttpStatus.INTERNAL_SERVER_ERROR); throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
} }
} }
async upsertPlace(data: Partial<PlaceSyria>) { async upsertPlace(data: Partial<BasePlace>) {
try { try {
if (!data.latitude || !data.longitude || (!data.name && !data.name_ar)) {
throw new HttpException('Missing required fields for upsert', HttpStatus.BAD_REQUEST);
}
const lng = Number(data.longitude);
const lat = Number(data.latitude); const lat = Number(data.latitude);
const lng = Number(data.longitude);
const repo = this.getRepositoryForCoords(lat, lng);
const tableName = this.getTableNameForRepo(repo);
// 1. Check for spatial match (within 10 meters) const existing = await repo.query(
const existingQuery = ` `SELECT id, name, name_ar FROM ${tableName} WHERE ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1, $2), 4326)) < 15 LIMIT 1`,
SELECT id FROM places_syria [lng, lat],
WHERE ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1, $2), 4326)) < 10 );
LIMIT 1
`;
const existing = await this.placesRepository.query(existingQuery, [lng, lat]);
if (existing && existing.length > 0) { if (existing && existing.length > 0) {
const id = existing[0].id; const id = existing[0].id;
this.logger.log(`Spatial match found for ${data.name || data.name_ar} (ID: ${id}). Updating...`); const currentName = existing[0].name_ar || existing[0].name || '';
const newName = data.name_ar || data.name || '';
await this.placesRepository.update(id, { // منطق "الاسم الأفضل": تحديث الاسم فقط إذا كان الجديد أطول أو يحتوي على تفاصيل أكثر
name: data.name || undefined, const shouldUpdateName = newName.length > currentName.length || (newName.includes('منزل') && !currentName.includes('منزل'));
name_ar: data.name_ar || undefined,
address: data.address || undefined, await repo.update(id, {
name: shouldUpdateName ? (data.name || undefined) : undefined,
name_ar: shouldUpdateName ? (data.name_ar || undefined) : undefined,
category: data.category || undefined, category: data.category || undefined,
description: data.description || undefined,
city: data.city || undefined, city: data.city || undefined,
neighbourhood: data.neighbourhood || undefined
}); });
return { id, action: 'updated' }; return { id, action: 'updated' };
} }
// 2. No matching place found, create new one
const result = await this.addPlace(data); const result = await this.addPlace(data);
return { id: result.id, action: 'created' }; return { id: result.id, action: 'created' };
} catch (error) { } catch (error) {
@@ -234,15 +164,30 @@ export class GeocodingService {
} }
} }
async getRecentPlaces(limit: number = 50) { async upsertBatch(places: Partial<BasePlace>[]) {
const results: { id: number; action: string }[] = [];
for (const place of places) {
try { try {
return this.placesRepository.find({ const res = await this.upsertPlace(place);
order: { created_at: 'DESC' }, results.push(res as { id: number; action: string });
take: limit, } catch (e) {
}); this.logger.error(`Batch item failed: ${place.name || place.name_ar}`, e.message);
} catch (error) {
this.logger.error('Failed to fetch recent places:', error);
return [];
} }
} }
return {
total: places.length, processed: results.length,
created: results.filter(r => r.action === 'created').length,
updated: results.filter(r => r.action === 'updated').length
};
}
async getRecentPlaces(limit: number = 50) {
const syria = await this.placesSyriaRepository.find({ order: { created_at: 'DESC' }, take: limit });
const jordan = await this.placesJordanRepository.find({ order: { created_at: 'DESC' }, take: limit });
const egypt = await this.placesEgyptRepository.find({ order: { created_at: 'DESC' }, take: limit });
return [...syria, ...jordan, ...egypt]
.sort((a, b) => b.created_at.getTime() - a.created_at.getTime())
.slice(0, limit);
}
} }
+195 -72
View File
@@ -3,114 +3,203 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Intaleq Premium Map - خرائط انطلاقة الذكية</title> <title>Intaleq Premium Map V3 - خرائط انطلاقة الذكية</title>
<link href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" rel="stylesheet" /> <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> <script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<style> <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; /* 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);
}
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; background: #f8f9fa; 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 { width: 100%; height: 100vh; position: absolute; top: 0; left: 0; }
.controls { .controls {
position: absolute; top: 20px; right: 20px; z-index: 100; position: absolute; top: 24px; right: 24px; z-index: 100;
background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(8px); background: var(--bg-glass);
border-radius: 16px; padding: 20px; width: 340px; backdrop-filter: blur(12px) saturate(180%);
box-shadow: 0 10px 25px rgba(0,0,0,0.1); border: 1px solid rgba(0,0,0,0.05); -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 { .branding {
position: absolute; bottom: 24px; left: 24px; z-index: 100; position: absolute; bottom: 32px; left: 32px; z-index: 100;
background: rgba(255, 255, 255, 0.9); padding: 8px 16px; border-radius: 8px; background: var(--bg-glass);
display: flex; align-items: center; gap: 10px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); backdrop-filter: blur(8px);
pointer-events: none; border: 1px solid rgba(0,0,0,0.05); 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 { .btn {
padding: 14px 20px; border: none; border-radius: 10px; position: relative; padding: 16px 20px; border: none; border-radius: 16px;
font-size: 15px; font-weight: 700; cursor: pointer; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); font-size: 15px; font-weight: 700; cursor: pointer;
width: 100%; margin-top: 12px; display: flex; align-items: center; justify-content: center; gap: 10px; 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: #2563eb; color: white; }
.btn-primary:hover { background: #1d4ed8; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3); } .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 { .info-box {
background: #f8fafc; border-radius: 10px; padding: 14px; font-size: 14px; color: #334155; margin-top: 16px; background: rgba(253, 250, 245, 0.6);
border-right: 4px solid #2563eb; line-height: 1.6; display: none; 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;
} }
#loading { display: none; position: fixed; inset: 0; background: rgba(255,255,255,0.7); z-index: 2000; align-items: center; justify-content: center; backdrop-filter: blur(4px); }
@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; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--primary); border-radius: 10px; }
</style> </style>
</head> </head>
<body> <body>
<div id="map"></div> <div id="map"></div>
<div id="loading"><strong>جاري معالجة المسار...</strong></div>
<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>
</div>
<div class="branding"> <div class="branding">
<img src="/intaleq-logo.png" alt="Logo" style="height: 20px;" onerror="this.style.display='none'"> <img src="/intaleq-logo.png" alt="Logo" style="height: 24px;" onerror="this.style.display='none'">
<span style="font-size: 12px; font-weight: 800; color: #0f172a; letter-spacing: 0.5px;">POWERED BY INTALEQ</span> <span style="font-size: 13px; font-weight: 800; color: #18100A; letter-spacing: 0.5px;">INTALEQ PREMIUM MAPS</span>
</div> </div>
<div class="controls"> <div class="controls">
<h2 style="font-size: 20px; color: #0f172a; margin-bottom: 6px;">🗺️ خرائط انطلاقة الذكية</h2> <div class="badge">Version 3.0.0</div>
<p style="font-size: 13px; color: #64748b; margin-bottom: 16px;">المسار المعتمد: عمان ➔ دمشق</p> <h2>🗺️ خرائط انطلاقة الذكية</h2>
<p>حلول جغرافية متقدمة لمنطقة الشرق الأوسط وشمال أفريقيا بدقة متناهية ونظام عرض ثلاثي الأبعاد.</p>
<button class="btn btn-primary" onclick="handleRoute()"> <div class="btn-group">
<span>🚀</span> <button class="btn btn-primary" onclick="handleRoute('LEVANT')">
<span>حساب المسار الذكي</span> <div style="text-align: right;">
<div style="font-size: 12px; opacity: 0.8; margin-bottom: 2px;">المسار المقترح - سوريا</div>
<span>عمان ➔ دمشق</span>
</div>
<span class="icon">🚀</span>
</button> </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>
</div>
<span class="icon">🇪🇬</span>
</button>
</div>
<div id="status" class="info-box"></div> <div id="status" class="info-box"></div>
</div> </div>
<script> <script>
const TILES_URL = 'https://tiles.intaleqapp.com'; const TILES_URL = 'https://tiles.intaleqapp.com';
const API_KEY = 'intaleq_secret_2026'; const API_KEY = 'intaleq_secret_2026';
const AMMAN = [35.9106, 31.9539];
const DAMASCUS = [36.2765, 33.5138]; const COORDS = {
LEVANT: {
from: [35.9106, 31.9539], // Amman
to: [36.2765, 33.5138], // Damascus
center: [36.09, 32.73],
zoom: 8
},
EGYPT: {
from: [31.2427, 30.0931], // Shoubra
to: [31.2201, 30.0619], // Zamalek
center: [31.23, 30.07],
zoom: 13
}
};
maplibregl.setRTLTextPlugin('https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.2.3/mapbox-gl-rtl-text.js', null, true); 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({ const map = new maplibregl.Map({
container: 'map', container: 'map',
center: AMMAN, center: COORDS.LEVANT.center,
zoom: 12, zoom: COORDS.LEVANT.zoom,
attributionControl: false, attributionControl: false,
style: './style-mobile.json?v=' + Date.now() style: './style.json?v=' + Date.now(),
pitch: 45,
bearing: -10
}); });
let mapLoaded = false;
map.on('load', () => { map.on('load', () => {
// إضافة مصدر المعالم الخاصة بدمشق (Places Syria) mapLoaded = true;
map.addSource('user-landmarks', { console.log('Intaleq Style v3 Loaded');
type: 'vector',
tiles: [`${TILES_URL}/places_syria/{z}/{x}/{y}`],
maxzoom: 14
});
// إضافة طبقة الأسماء بدقة عالية تندمج مع الستايل الأصلي
map.addLayer({
id: 'user-landmarks-labels',
type: 'symbol',
source: 'user-landmarks',
'source-layer': 'places_syria',
minzoom: 15, // تظهر فقط عند التقريب الشديد للحفاظ على النظافة
layout: {
'text-field': '{name_ar}',
'text-font': ['Noto Sans Regular'],
'text-size': 13,
'text-anchor': 'top',
'text-offset': [0, 1.2],
'text-letter-spacing': 0.05
},
paint: {
'text-color': '#3c4043', // تطابق لون أسماء الشوارع والمعالم الأصلية
'text-halo-color': 'rgba(255, 255, 255, 0.9)',
'text-halo-width': 2
}
});
// تحميل أيقونة الأسهم
map.loadImage('/icons/arrow.svg', (error, image) => {
if (error) console.error('Error loading arrow icon:', error);
else if (!map.hasImage('arrow')) map.addImage('arrow', image, { sdf: true });
});
}); });
function decodePoly(str) { function decodePoly(str) {
@@ -127,12 +216,18 @@
return coordinates; return coordinates;
} }
async function handleRoute() { async function handleRoute(region) {
if (!mapLoaded) return;
const config = COORDS[region];
const l = document.getElementById('loading'); const l = document.getElementById('loading');
const s = document.getElementById('status'); const s = document.getElementById('status');
l.style.display = 'flex'; l.style.display = 'flex';
s.style.display = 'none';
try { try {
const url = `https://map-saas.intaleqapp.com/api/maps/route?fromLat=${AMMAN[1]}&fromLng=${AMMAN[0]}&toLat=${DAMASCUS[1]}&toLng=${DAMASCUS[0]}`; 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 res = await fetch(url, { headers: { 'x-api-key': API_KEY } });
const data = await res.json(); const data = await res.json();
@@ -141,23 +236,51 @@
let coords = typeof data.points === 'string' ? decodePoly(data.points) : data.points; 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 (!coords || coords.length === 0) throw new Error('No valid route points found');
// Add/Update Route Source
if (map.getSource('route')) { if (map.getSource('route')) {
map.getSource('route').setData({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords } }); map.getSource('route').setData({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords } });
} else { } else {
map.addSource('route', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates: coords } } }); map.addSource('route', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates: coords } } });
map.addLayer({ id: 'route-line', type: 'line', source: 'route', paint: { 'line-color': '#2563eb', 'line-width': 8, 'line-opacity': 0.85 } }); 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
}
});
} }
// Fit bounds
const bounds = coords.reduce((b, c) => b.extend(c), new maplibregl.LngLatBounds(coords[0], coords[0])); const bounds = coords.reduce((b, c) => b.extend(c), new maplibregl.LngLatBounds(coords[0], coords[0]));
map.fitBounds(bounds, { padding: 80 }); map.fitBounds(bounds, { padding: 120, duration: 2000 });
setTimeout(() => {
s.style.display = 'block'; s.style.display = 'block';
s.innerHTML = `🏁 المسافة: ${(data.distance/1000).toFixed(1)} كم | ⏱ الوقت: ${(data.duration/60).toFixed(0)} دقيقة`; 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) { } catch (e) {
alert('خطأ في حساب المسارات: ' + e.message);
console.error(e); console.error(e);
} finally { l.style.display = 'none'; } alert('عذراً، فشل في حساب المسار: ' + e.message);
} finally {
l.style.display = 'none';
}
} }
</script> </script>
</body> </body>
</html> </html>
+785 -303
View File
File diff suppressed because it is too large Load Diff
+48 -1
View File
@@ -18,6 +18,25 @@ function App() {
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
const [newPlace, setNewPlace] = useState<{lat: number, lng: number} | null>(null); const [newPlace, setNewPlace] = useState<{lat: number, lng: number} | null>(null);
const [placeForm, setPlaceForm] = useState({ name: '', name_ar: '', category: '' }); const [placeForm, setPlaceForm] = useState({ name: '', name_ar: '', category: '' });
const [currentRegion, setCurrentRegion] = useState('Jordan');
const regions = [
{ name: 'Syria', name_ar: 'سوريا', center: [36.29, 33.51], zoom: 12, flag: '🇸🇾' },
{ name: 'Jordan', name_ar: 'الأردن', center: [35.91, 31.95], zoom: 12, flag: '🇯🇴' },
{ name: 'Egypt', name_ar: 'مصر', center: [31.23, 30.04], zoom: 11, flag: '🇪🇬' },
];
const handleRegionSwitch = (region: any) => {
setCurrentRegion(region.name);
if (map) {
map.flyTo({
center: region.center,
zoom: region.zoom,
essential: true,
duration: 3000
});
}
};
const handleMapClick = (lat: number, lng: number) => { const handleMapClick = (lat: number, lng: number) => {
setNewPlace({ lat, lng }); setNewPlace({ lat, lng });
@@ -146,9 +165,37 @@ function App() {
return ( return (
<div className="app"> <div className="app">
<div className="sidebar glass-morphism"> <div className="sidebar glass-morphism">
<h1>Jordan Maps SaaS</h1> <h1>{currentRegion} Maps SaaS</h1>
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>Self-Hosted Mobility Prototype</p> <p style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>Self-Hosted Mobility Prototype</p>
<div className="region-selector" style={{ display: 'flex', gap: '8px', margin: '15px 0' }}>
{regions.map(r => (
<button
key={r.name}
onClick={() => handleRegionSwitch(r)}
className={`region-btn ${currentRegion === r.name ? 'active' : ''}`}
style={{
flex: 1,
padding: '8px 4px',
borderRadius: '8px',
border: currentRegion === r.name ? '1px solid #3b82f6' : '1px solid var(--glass-border)',
background: currentRegion === r.name ? 'rgba(59, 130, 246, 0.1)' : 'transparent',
color: 'var(--text-main)',
cursor: 'pointer',
fontSize: '0.75rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px',
transition: 'all 0.2s'
}}
>
<span style={{ fontSize: '1.2rem' }}>{r.flag}</span>
<span>{r.name_ar}</span>
</button>
))}
</div>
<div className="input-group"> <div className="input-group">
<label><MapPin size={14} style={{ marginRight: 5 }} /> Search / البحث</label> <label><MapPin size={14} style={{ marginRight: 5 }} /> Search / البحث</label>
<div style={{ display: 'flex', gap: '5px' }}> <div style={{ display: 'flex', gap: '5px' }}>
+1 -1
View File
@@ -46,7 +46,7 @@ services:
volumes: volumes:
- ./infrastructure/osm-data:/data - ./infrastructure/osm-data:/data
- ./infrastructure/docker/graphhopper/config.yml:/graphhopper/config.yml - ./infrastructure/docker/graphhopper/config.yml:/graphhopper/config.yml
command: ["-i", "/data/region.osm.pbf", "-c", "config.yml"] command: ["-i", "/data/mena_full.osm.pbf", "-c", "config.yml"]
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"] test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
interval: 30s interval: 30s
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# Script to import Egypt and Jordan OSM data into the PostGIS database
# نص برمجي لاستيراد بيانات مصر والأردن إلى قاعدة البيانات
set -e
DATA_DIR="./infrastructure/osm-data"
DB_USER=${POSTGRES_USER:-mapuser}
DB_NAME=${POSTGRES_DB:-mapdb}
echo "🌍 Starting MENA Regional Data Import..."
echo "🌍 البدء في استيراد البيانات الإقليمية..."
# 1. Check for files
if [ ! -f "$DATA_DIR/jordan-latest.osm.pbf" ]; then
echo "❌ Jordan PBF missing. Please run setup-osm.sh first."
exit 1
fi
if [ ! -f "$DATA_DIR/egypt-latest.osm.pbf" ]; then
echo "📥 Downloading Egypt OSM PBF..."
curl -L https://download.geofabrik.de/africa/egypt-latest.osm.pbf -o "$DATA_DIR/egypt-latest.osm.pbf"
fi
# 2. Detect Docker Compose Command
if command -v docker-compose &> /dev/null; then
DOKCER_COMPOSE="docker-compose"
else
DOKCER_COMPOSE="docker compose"
fi
echo "🐳 Using $DOKCER_COMPOSE..."
# 3. Import Jordan (Append)
echo "🇯🇴 Importing Jordan data (Append mode)..."
$DOKCER_COMPOSE --profile import run --rm osm-import osm2pgsql \
--append --slim --cache 1000 \
--database "$DB_NAME" --host db --user "$DB_USER" \
/data/jordan-latest.osm.pbf
# 4. Import Egypt (Append)
echo "🇪🇬 Importing Egypt data (Append mode)..."
$DOKCER_COMPOSE --profile import run --rm osm-import osm2pgsql \
--append --slim --cache 1000 \
--database "$DB_NAME" --host db --user "$DB_USER" \
/data/egypt-latest.osm.pbf
echo "✅ Import complete. Restarting tile and routing services..."
$DOKCER_COMPOSE restart martin routing
echo "🚀 MENA Regional mapping is now live!"
echo "🚀 تم تفعيل خرائط المنطقة بنجاح!"