feat: upgrade to Intaleq Style v3 and enable Egypt routing demo (code only)
This commit is contained in:
@@ -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
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+48
-1
@@ -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
@@ -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
|
||||||
|
|||||||
+1340
-114
File diff suppressed because it is too large
Load Diff
@@ -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 "🚀 تم تفعيل خرائط المنطقة بنجاح!"
|
||||||
Reference in New Issue
Block a user