Backup before syncing server scripts
This commit is contained in:
@@ -63,8 +63,12 @@ export class AdministrativeLinkingService {
|
|||||||
// Determine country from BBox if not provided (simple heuristic)
|
// Determine country from BBox if not provided (simple heuristic)
|
||||||
let detectedCountry = country;
|
let detectedCountry = country;
|
||||||
if (!detectedCountry) {
|
if (!detectedCountry) {
|
||||||
const firstLat = parseFloat(bbox.split(',')[0]);
|
const parts = bbox.split(',').map(parseFloat);
|
||||||
if (firstLat > 32.3) detectedCountry = 'syria';
|
const firstLat = parts[0];
|
||||||
|
const lngs = [parts[1], parts[3]].filter(n => !isNaN(n));
|
||||||
|
const maxLng = lngs.length ? Math.max(...lngs) : undefined;
|
||||||
|
if (maxLng !== undefined && maxLng > 42.5) detectedCountry = 'iraq';
|
||||||
|
else if (firstLat > 32.3) detectedCountry = 'syria';
|
||||||
else if (firstLat < 31.0) detectedCountry = 'egypt';
|
else if (firstLat < 31.0) detectedCountry = 'egypt';
|
||||||
else detectedCountry = 'jordan';
|
else detectedCountry = 'jordan';
|
||||||
}
|
}
|
||||||
@@ -98,8 +102,13 @@ export class AdministrativeLinkingService {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
-- حدّ 50كم إلزامي: بدونه تُسنَد نقطة لا تقع داخل أي حدود إلى أقرب
|
||||||
|
-- مقاطعة في الجدول كله مهما بَعُدت. حين استوردنا العراق قبل حدوده
|
||||||
|
-- الإدارية، رُبطت أحياء بغداد الـ185 بمقاطعات سورية/أردنية على بعد
|
||||||
|
-- ~700كم. عدم الربط أفضل من ربط خاطئ صامت.
|
||||||
SELECT id FROM admin_boundaries ab
|
SELECT id FROM admin_boundaries ab
|
||||||
WHERE ab.admin_level IN (6, 8)
|
WHERE ab.admin_level IN (6, 8)
|
||||||
|
AND ST_DWithin(ab.geom::geography, np.geometry::geography, 50000)
|
||||||
ORDER BY ab.geom::geometry <-> np.geometry::geometry
|
ORDER BY ab.geom::geometry <-> np.geometry::geometry
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
)
|
)
|
||||||
@@ -209,9 +218,9 @@ export class AdministrativeLinkingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 3: Link all Places (Jordan, Syria, Egypt) to the full administrative hierarchy
|
* Step 3: Link all Places (Jordan, Syria, Egypt, Iraq) to the full administrative hierarchy
|
||||||
*/
|
*/
|
||||||
async linkPlaces(country: 'jordan' | 'syria' | 'egypt') {
|
async linkPlaces(country: 'jordan' | 'syria' | 'egypt' | 'iraq') {
|
||||||
const tableName = `places_${country}`;
|
const tableName = `places_${country}`;
|
||||||
|
|
||||||
// Pre-flight diagnostics
|
// Pre-flight diagnostics
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class SearchQueryDto {
|
|||||||
radius?: number = 20000;
|
radius?: number = 20000;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(['jordan', 'syria', 'egypt'])
|
@IsEnum(['jordan', 'syria', 'egypt', 'iraq'])
|
||||||
@ApiPropertyOptional({ description: 'Country filter', enum: ['jordan', 'syria', 'egypt'] })
|
@ApiPropertyOptional({ description: 'Country filter', enum: ['jordan', 'syria', 'egypt', 'iraq'] })
|
||||||
country?: string;
|
country?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ export enum CandidateStatus {
|
|||||||
export enum CountryCode {
|
export enum CountryCode {
|
||||||
JORDAN = 'JORDAN',
|
JORDAN = 'JORDAN',
|
||||||
SYRIA = 'SYRIA',
|
SYRIA = 'SYRIA',
|
||||||
EGYPT = 'EGYPT'
|
EGYPT = 'EGYPT',
|
||||||
|
IRAQ = 'IRAQ'
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity('map_candidates')
|
@Entity('map_candidates')
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Entity } from 'typeorm';
|
||||||
|
import { BasePlace } from './base-place.entity';
|
||||||
|
|
||||||
|
@Entity('places_iraq')
|
||||||
|
export class PlaceIraq extends BasePlace {}
|
||||||
@@ -20,7 +20,7 @@ export class GeocodingInitService implements OnModuleInit {
|
|||||||
await this.repo.query('CREATE EXTENSION IF NOT EXISTS pg_trgm;');
|
await this.repo.query('CREATE EXTENSION IF NOT EXISTS pg_trgm;');
|
||||||
|
|
||||||
// Migration: Rename old admin_level columns to descriptive names if they exist
|
// Migration: Rename old admin_level columns to descriptive names if they exist
|
||||||
const tables = ['places_jordan', 'places_syria', 'places_egypt'];
|
const tables = ['places_jordan', 'places_syria', 'places_egypt', 'places_iraq'];
|
||||||
const columnMapping = [
|
const columnMapping = [
|
||||||
{ old: 'admin_level4_id', new: 'governorate_id' },
|
{ old: 'admin_level4_id', new: 'governorate_id' },
|
||||||
{ old: 'admin_level6_id', new: 'district_id' },
|
{ old: 'admin_level6_id', new: 'district_id' },
|
||||||
@@ -83,15 +83,24 @@ export class GeocodingInitService implements OnModuleInit {
|
|||||||
FOR EACH ROW EXECUTE FUNCTION sync_place_location();
|
FOR EACH ROW EXECUTE FUNCTION sync_place_location();
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// 4. GIST Geometry Indexes for Jordan and Egypt
|
await this.repo.query(`
|
||||||
|
DROP TRIGGER IF EXISTS trg_sync_place_location_iraq ON places_iraq;
|
||||||
|
CREATE TRIGGER trg_sync_place_location_iraq
|
||||||
|
BEFORE INSERT OR UPDATE ON places_iraq
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION sync_place_location();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 4. GIST Geometry Indexes for Jordan, Egypt and Iraq
|
||||||
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_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);');
|
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_places_egypt_location ON places_egypt USING gist (location);');
|
||||||
|
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_places_iraq_location ON places_iraq USING gist (location);');
|
||||||
|
|
||||||
// 5. GIST Trigram Indexes for Jordan and Egypt
|
// 5. GIST Trigram Indexes for Jordan, Egypt and Iraq
|
||||||
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_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);');
|
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);');
|
||||||
|
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_places_iraq_names_trgm ON places_iraq 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.');
|
this.logger.log('Geocoding database triggers and optimized indexes initialized for Syria, Jordan, Egypt, and Iraq.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('Failed to initialize database geocoding triggers:', err);
|
this.logger.error('Failed to initialize database geocoding triggers:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export class GeocodingController {
|
|||||||
@ApiOperation({ summary: 'Delete a place by name or ID' })
|
@ApiOperation({ summary: 'Delete a place by name or ID' })
|
||||||
@ApiQuery({ name: 'name', required: false })
|
@ApiQuery({ name: 'name', required: false })
|
||||||
@ApiQuery({ name: 'id', required: false, type: Number })
|
@ApiQuery({ name: 'id', required: false, type: Number })
|
||||||
@ApiQuery({ name: 'country', required: true, enum: ['jordan', 'syria', 'egypt'] })
|
@ApiQuery({ name: 'country', required: true, enum: ['jordan', 'syria', 'egypt', 'iraq'] })
|
||||||
async deletePlace(
|
async deletePlace(
|
||||||
@Query('country') country: string,
|
@Query('country') country: string,
|
||||||
@Query('name') name?: string,
|
@Query('name') name?: string,
|
||||||
@@ -124,7 +124,7 @@ export class GeocodingController {
|
|||||||
@UseGuards(AdminGuard)
|
@UseGuards(AdminGuard)
|
||||||
@ApiOperation({ summary: 'Sync neighborhood points from OSM for a bbox' })
|
@ApiOperation({ summary: 'Sync neighborhood points from OSM for a bbox' })
|
||||||
@ApiQuery({ name: 'bbox', required: false })
|
@ApiQuery({ name: 'bbox', required: false })
|
||||||
@ApiQuery({ name: 'country', required: false, enum: ['jordan', 'syria', 'egypt'] })
|
@ApiQuery({ name: 'country', required: false, enum: ['jordan', 'syria', 'egypt', 'iraq'] })
|
||||||
async syncNeighborhoods(@Query('bbox') bbox?: string, @Query('country') country?: string) {
|
async syncNeighborhoods(@Query('bbox') bbox?: string, @Query('country') country?: string) {
|
||||||
return this.adminLinkingService.syncOsmNeighborhoodPoints(bbox, country);
|
return this.adminLinkingService.syncOsmNeighborhoodPoints(bbox, country);
|
||||||
}
|
}
|
||||||
@@ -132,7 +132,7 @@ export class GeocodingController {
|
|||||||
@Post('admin/generate-voronoi')
|
@Post('admin/generate-voronoi')
|
||||||
@UseGuards(AdminGuard)
|
@UseGuards(AdminGuard)
|
||||||
@ApiOperation({ summary: 'Generate Voronoi polygons for neighborhoods' })
|
@ApiOperation({ summary: 'Generate Voronoi polygons for neighborhoods' })
|
||||||
@ApiQuery({ name: 'country', required: false, enum: ['jordan', 'syria', 'egypt'] })
|
@ApiQuery({ name: 'country', required: false, enum: ['jordan', 'syria', 'egypt', 'iraq'] })
|
||||||
async generateVoronoi(@Query('country') country?: string) {
|
async generateVoronoi(@Query('country') country?: string) {
|
||||||
return this.adminLinkingService.generateVoronoiNeighborhoods(country);
|
return this.adminLinkingService.generateVoronoiNeighborhoods(country);
|
||||||
}
|
}
|
||||||
@@ -140,8 +140,8 @@ export class GeocodingController {
|
|||||||
@Post('admin/link-places')
|
@Post('admin/link-places')
|
||||||
@UseGuards(AdminGuard)
|
@UseGuards(AdminGuard)
|
||||||
@ApiOperation({ summary: 'Link places to administrative hierarchy' })
|
@ApiOperation({ summary: 'Link places to administrative hierarchy' })
|
||||||
@ApiQuery({ name: 'country', required: true, enum: ['jordan', 'syria', 'egypt'] })
|
@ApiQuery({ name: 'country', required: true, enum: ['jordan', 'syria', 'egypt', 'iraq'] })
|
||||||
async linkPlaces(@Query('country') country: 'jordan' | 'syria' | 'egypt') {
|
async linkPlaces(@Query('country') country: 'jordan' | 'syria' | 'egypt' | 'iraq') {
|
||||||
return this.adminLinkingService.linkPlaces(country);
|
return this.adminLinkingService.linkPlaces(country);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 { PlaceJordan } from './entities/place-jordan.entity';
|
||||||
import { PlaceEgypt } from './entities/place-egypt.entity';
|
import { PlaceEgypt } from './entities/place-egypt.entity';
|
||||||
|
import { PlaceIraq } from './entities/place-iraq.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';
|
||||||
import { AdminBoundary } from './entities/admin-boundary.entity';
|
import { AdminBoundary } from './entities/admin-boundary.entity';
|
||||||
@@ -27,6 +28,7 @@ import { IndexRefreshService } from './index-refresh.service';
|
|||||||
PlaceSyria,
|
PlaceSyria,
|
||||||
PlaceJordan,
|
PlaceJordan,
|
||||||
PlaceEgypt,
|
PlaceEgypt,
|
||||||
|
PlaceIraq,
|
||||||
OsmArea,
|
OsmArea,
|
||||||
OsmPointWithArea,
|
OsmPointWithArea,
|
||||||
AdminBoundary,
|
AdminBoundary,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Cache } from 'cache-manager';
|
|||||||
import { PlaceSyria } from './entities/place-syria.entity';
|
import { PlaceSyria } from './entities/place-syria.entity';
|
||||||
import { PlaceJordan } from './entities/place-jordan.entity';
|
import { PlaceJordan } from './entities/place-jordan.entity';
|
||||||
import { PlaceEgypt } from './entities/place-egypt.entity';
|
import { PlaceEgypt } from './entities/place-egypt.entity';
|
||||||
|
import { PlaceIraq } from './entities/place-iraq.entity';
|
||||||
import { BasePlace } from './entities/base-place.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';
|
||||||
@@ -23,6 +24,8 @@ export class GeocodingService {
|
|||||||
private placesJordanRepository: Repository<PlaceJordan>,
|
private placesJordanRepository: Repository<PlaceJordan>,
|
||||||
@InjectRepository(PlaceEgypt)
|
@InjectRepository(PlaceEgypt)
|
||||||
private placesEgyptRepository: Repository<PlaceEgypt>,
|
private placesEgyptRepository: Repository<PlaceEgypt>,
|
||||||
|
@InjectRepository(PlaceIraq)
|
||||||
|
private placesIraqRepository: Repository<PlaceIraq>,
|
||||||
@InjectRepository(OsmArea)
|
@InjectRepository(OsmArea)
|
||||||
private osmAreasRepository: Repository<OsmArea>,
|
private osmAreasRepository: Repository<OsmArea>,
|
||||||
@InjectRepository(OsmPointWithArea)
|
@InjectRepository(OsmPointWithArea)
|
||||||
@@ -35,6 +38,11 @@ export class GeocodingService {
|
|||||||
* تحديد المستودع المناسب بناءً على الإحداثيات الجغرافية
|
* تحديد المستودع المناسب بناءً على الإحداثيات الجغرافية
|
||||||
*/
|
*/
|
||||||
private getRepositoryForCoords(lat: number, lng: number): Repository<BasePlace> {
|
private getRepositoryForCoords(lat: number, lng: number): Repository<BasePlace> {
|
||||||
|
// Iraq checked first: its western desert (Anbar) extends to lng ~38.7, which
|
||||||
|
// overlaps the Jordan/Syria box below (lng <= 42.5). Order matters here.
|
||||||
|
if (lat >= 29 && lat <= 37.5 && lng >= 38.7 && lng <= 48.8) {
|
||||||
|
return (this.placesIraqRepository as unknown) as Repository<BasePlace>;
|
||||||
|
}
|
||||||
if (lat >= 29 && lat <= 37.5 && lng >= 34.5 && lng <= 42.5) {
|
if (lat >= 29 && lat <= 37.5 && lng >= 34.5 && lng <= 42.5) {
|
||||||
if (lat > 32.5 && lng > 35.8) return (this.placesSyriaRepository as unknown) as Repository<BasePlace>;
|
if (lat > 32.5 && lng > 35.8) return (this.placesSyriaRepository as unknown) as Repository<BasePlace>;
|
||||||
return (this.placesJordanRepository as unknown) as Repository<BasePlace>;
|
return (this.placesJordanRepository as unknown) as Repository<BasePlace>;
|
||||||
@@ -47,6 +55,9 @@ export class GeocodingService {
|
|||||||
|
|
||||||
private identifyRegion(lat?: number, lng?: number): string | undefined {
|
private identifyRegion(lat?: number, lng?: number): string | undefined {
|
||||||
if (lat === undefined || lng === undefined) return undefined;
|
if (lat === undefined || lng === undefined) return undefined;
|
||||||
|
if (lat >= 29 && lat <= 37.5 && lng >= 38.7 && lng <= 48.8) {
|
||||||
|
return 'iraq';
|
||||||
|
}
|
||||||
if (lat >= 29 && lat <= 37.5 && lng >= 34.5 && lng <= 42.5) {
|
if (lat >= 29 && lat <= 37.5 && lng >= 34.5 && lng <= 42.5) {
|
||||||
if (lat > 32.5 && lng > 35.8) return 'syria';
|
if (lat > 32.5 && lng > 35.8) return 'syria';
|
||||||
return 'jordan';
|
return 'jordan';
|
||||||
@@ -60,6 +71,7 @@ export class GeocodingService {
|
|||||||
private getTableNameForRepo(repo: Repository<BasePlace>): string {
|
private getTableNameForRepo(repo: Repository<BasePlace>): string {
|
||||||
if (repo === (this.placesJordanRepository as unknown)) return 'places_jordan';
|
if (repo === (this.placesJordanRepository as unknown)) return 'places_jordan';
|
||||||
if (repo === (this.placesEgyptRepository as unknown)) return 'places_egypt';
|
if (repo === (this.placesEgyptRepository as unknown)) return 'places_egypt';
|
||||||
|
if (repo === (this.placesIraqRepository as unknown)) return 'places_iraq';
|
||||||
return 'places_syria';
|
return 'places_syria';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +110,7 @@ export class GeocodingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let regionCondition = '';
|
let regionCondition = '';
|
||||||
if (targetRegion && ['syria', 'jordan', 'egypt'].includes(targetRegion)) {
|
if (targetRegion && ['syria', 'jordan', 'egypt', 'iraq'].includes(targetRegion)) {
|
||||||
regionCondition = `AND (region = '${targetRegion}' OR region = 'global')`;
|
regionCondition = `AND (region = '${targetRegion}' OR region = 'global')`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +263,7 @@ export class GeocodingService {
|
|||||||
|
|
||||||
private getRepoByTableName(tableName: string): Repository<any> {
|
private getRepoByTableName(tableName: string): Repository<any> {
|
||||||
if (tableName === 'places_egypt') return this.placesEgyptRepository;
|
if (tableName === 'places_egypt') return this.placesEgyptRepository;
|
||||||
|
if (tableName === 'places_iraq') return this.placesIraqRepository;
|
||||||
if (tableName === 'places_jordan') return this.placesJordanRepository;
|
if (tableName === 'places_jordan') return this.placesJordanRepository;
|
||||||
return this.placesSyriaRepository;
|
return this.placesSyriaRepository;
|
||||||
}
|
}
|
||||||
@@ -296,7 +309,7 @@ export class GeocodingService {
|
|||||||
|
|
||||||
let queryParams: any[] = [`${normalizedQuery}%`];
|
let queryParams: any[] = [`${normalizedQuery}%`];
|
||||||
let regionCondition = '';
|
let regionCondition = '';
|
||||||
if (targetRegion && ['syria', 'jordan', 'egypt'].includes(targetRegion)) {
|
if (targetRegion && ['syria', 'jordan', 'egypt', 'iraq'].includes(targetRegion)) {
|
||||||
regionCondition = `AND (region = '${targetRegion}' OR region = 'global')`;
|
regionCondition = `AND (region = '${targetRegion}' OR region = 'global')`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,10 +349,62 @@ export class GeocodingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* العنوان العراقي بنية رقمية: محافظة ← منطقة ← محلة ← زقاق ← دار.
|
||||||
|
* المحلة والزقاق هما ما يستعمله الناس فعلياً، لا اسم الشارع.
|
||||||
|
*
|
||||||
|
* في OSM يُخزَّن الزقاق كاسم الطريق نفسه بصيغة "647-19" (محلة-زقاق) داخل
|
||||||
|
* planet_osm_line — وهو الجدول الوحيد الذي يحملها، ولم يكن reverseGeocode
|
||||||
|
* يستعلمه إطلاقاً. لذا كان السائق يقف داخل زقاق 19 ولا نملك ما نسمّيه به.
|
||||||
|
*
|
||||||
|
* هندسة planet_osm_line بـ SRID 3857 (افتراضي osm2pgsql بلا -E)، لذا نحوّل
|
||||||
|
* نقطة البحث إليها ليعمل فهرس GIST؛ ثم نقيس المسافة الحقيقية على geography
|
||||||
|
* للفائز وحده — فمسافات 3857 منتفخة بنحو 19% عند خط عرض بغداد.
|
||||||
|
*/
|
||||||
|
private async findIraqiAddress(lat: number, lng: number) {
|
||||||
|
const MAX_DISTANCE_M = 150; // أبعد من ذلك لم يعد الزقاق وصفاً للموقع
|
||||||
|
try {
|
||||||
|
const rows = await this.osmPointsRepository.query(
|
||||||
|
`
|
||||||
|
WITH p AS (
|
||||||
|
SELECT ST_Transform(ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326), 3857) AS g3857,
|
||||||
|
ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)::geography AS geog
|
||||||
|
)
|
||||||
|
SELECT l.name,
|
||||||
|
ST_Distance(ST_Transform(l.way, 4326)::geography, p.geog) AS distance
|
||||||
|
FROM planet_osm_line l, p
|
||||||
|
WHERE l.name ~ '^[0-9]{2,4}-[0-9]{1,3}$'
|
||||||
|
ORDER BY l.way <-> p.g3857
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[lng, lat],
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = rows?.[0];
|
||||||
|
if (!row || Number(row.distance) > MAX_DISTANCE_M) return null;
|
||||||
|
|
||||||
|
const [mahalla, zuqaq] = String(row.name).split('-');
|
||||||
|
return {
|
||||||
|
mahalla,
|
||||||
|
zuqaq,
|
||||||
|
distance: Number(row.distance),
|
||||||
|
text: `محلة ${mahalla}، زقاق ${zuqaq}`,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`Iraqi address lookup failed: ${e.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async reverseGeocode(lat: number, lng: number) {
|
async reverseGeocode(lat: number, lng: number) {
|
||||||
try {
|
try {
|
||||||
const repo = this.getRepositoryForCoords(lat, lng);
|
const repo = this.getRepositoryForCoords(lat, lng);
|
||||||
const tableName = this.getTableNameForRepo(repo);
|
const tableName = this.getTableNameForRepo(repo);
|
||||||
|
// نطلقه بالتوازي مع بقية الاستعلامات؛ العراق وحده يدفع تكلفته
|
||||||
|
const iraqiAddressPromise =
|
||||||
|
this.identifyRegion(lat, lng) === 'iraq'
|
||||||
|
? this.findIraqiAddress(lat, lng)
|
||||||
|
: Promise.resolve(null);
|
||||||
const queryPromises: Promise<any[]>[] = [];
|
const queryPromises: Promise<any[]>[] = [];
|
||||||
|
|
||||||
queryPromises.push(repo.query(`
|
queryPromises.push(repo.query(`
|
||||||
@@ -402,7 +467,10 @@ export class GeocodingService {
|
|||||||
ORDER BY location <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
|
ORDER BY location <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
|
||||||
`, [lng, lat]));
|
`, [lng, lat]));
|
||||||
|
|
||||||
const results = await Promise.allSettled(queryPromises);
|
const [results, iraqiAddress] = await Promise.all([
|
||||||
|
Promise.allSettled(queryPromises),
|
||||||
|
iraqiAddressPromise,
|
||||||
|
]);
|
||||||
let allResults: any[] = [];
|
let allResults: any[] = [];
|
||||||
results.forEach(res => {
|
results.forEach(res => {
|
||||||
if (res.status === 'fulfilled' && res.value) allResults.push(...res.value);
|
if (res.status === 'fulfilled' && res.value) allResults.push(...res.value);
|
||||||
@@ -413,7 +481,17 @@ export class GeocodingService {
|
|||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map(r => {
|
.map(r => {
|
||||||
const distance = Number(r.distance);
|
const distance = Number(r.distance);
|
||||||
const fullAddressParts = [r.name_ar || r.name, r.address, r.neighbourhood, r.district, r.governorate].filter(Boolean);
|
const ZUQAQ_NAME_RE = /^[0-9]{2,4}-[0-9]{1,3}$/;
|
||||||
|
const rawName = r.name_ar || r.name;
|
||||||
|
const nameIsZuqaq = typeof rawName === 'string' && ZUQAQ_NAME_RE.test(rawName);
|
||||||
|
const fullAddressParts = [
|
||||||
|
nameIsZuqaq ? null : rawName, // "605-9" كنتيجة بحث لا يضيف شيئاً فوق "زقاق 9"
|
||||||
|
r.address,
|
||||||
|
iraqiAddress?.text,
|
||||||
|
r.neighbourhood,
|
||||||
|
r.district,
|
||||||
|
r.governorate,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
let humanReadable = r.name_ar || r.name;
|
let humanReadable = r.name_ar || r.name;
|
||||||
if (distance <= 20) {
|
if (distance <= 20) {
|
||||||
@@ -430,8 +508,18 @@ export class GeocodingService {
|
|||||||
humanReadable = [streetPart, districtPart].filter(Boolean).join('، ');
|
humanReadable = [streetPart, districtPart].filter(Boolean).join('، ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// في العراق المحلة والزقاق هما العنوان الفعلي، فيتصدّران الوصف
|
||||||
|
// ويبقى المعلم القريب لاحقةً توضيحية — إلا إذا كان "المعلم" نفسه
|
||||||
|
// مجرد اسم زقاق آخر (مثل "605-9")، فتكرار الرقم لا يفيد أحداً.
|
||||||
|
if (iraqiAddress) {
|
||||||
|
const landmark = distance <= 70 && !nameIsZuqaq ? rawName : null;
|
||||||
|
humanReadable = [iraqiAddress.text, landmark].filter(Boolean).join(' — ');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...r,
|
...r,
|
||||||
|
mahalla: iraqiAddress?.mahalla,
|
||||||
|
zuqaq: iraqiAddress?.zuqaq,
|
||||||
latitude: parseFloat(r.latitude),
|
latitude: parseFloat(r.latitude),
|
||||||
longitude: parseFloat(r.longitude),
|
longitude: parseFloat(r.longitude),
|
||||||
human_readable_address: humanReadable,
|
human_readable_address: humanReadable,
|
||||||
@@ -444,20 +532,50 @@ export class GeocodingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* يقبل lat/lng أو latitude/longitude ويرفض ما ليس رقماً.
|
||||||
|
*
|
||||||
|
* سبب وجوده: العملاء يرسلون {lat, lng} (كما في POST /geocoding/places)، بينما
|
||||||
|
* addPlace كان يقرأ data.latitude فقط → Number(undefined) = NaN. عندها تفشل كل
|
||||||
|
* مقارنات getRepositoryForCoords فيسقط الاستدعاء على مستودع سوريا الافتراضي،
|
||||||
|
* فيظهر مكان أردني في دمشق بإحداثيات تالفة. الصمت هنا أسوأ من الخطأ.
|
||||||
|
*/
|
||||||
|
private extractCoords(data: any): { lat: number; lng: number } {
|
||||||
|
const lat = Number(data?.lat ?? data?.latitude);
|
||||||
|
const lng = Number(data?.lng ?? data?.longitude);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
|
||||||
|
throw new HttpException(
|
||||||
|
'Invalid coordinates: provide numeric lat/lng (or latitude/longitude)',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
||||||
|
throw new HttpException(
|
||||||
|
`Coordinates out of range: lat=${lat}, lng=${lng}`,
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { lat, lng };
|
||||||
|
}
|
||||||
|
|
||||||
async addPlace(data: Partial<BasePlace>) {
|
async addPlace(data: Partial<BasePlace>) {
|
||||||
try {
|
try {
|
||||||
const lat = Number(data.latitude), lng = Number(data.longitude);
|
const { lat, lng } = this.extractCoords(data);
|
||||||
const repo = this.getRepositoryForCoords(lat, lng), tableName = this.getTableNameForRepo(repo);
|
const repo = this.getRepositoryForCoords(lat, lng), tableName = this.getTableNameForRepo(repo);
|
||||||
const newPlace = repo.create({ ...data, latitude: lat, longitude: lng, created_at: new Date(), location: { type: 'Point', coordinates: [lng, lat] } });
|
const newPlace = repo.create({ ...data, latitude: lat, longitude: lng, created_at: new Date(), location: { type: 'Point', coordinates: [lng, lat] } });
|
||||||
const savedPlace = await repo.save(newPlace);
|
const savedPlace = await repo.save(newPlace);
|
||||||
await repo.query(`UPDATE ${tableName} SET location = ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) WHERE id = $3`, [lng, lat, savedPlace.id]);
|
await repo.query(`UPDATE ${tableName} SET location = ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) WHERE id = $3`, [lng, lat, savedPlace.id]);
|
||||||
return { ...savedPlace, latitude: lat, longitude: lng, location: `POINT(${lng} ${lat})` };
|
return { ...savedPlace, latitude: lat, longitude: lng, location: `POINT(${lng} ${lat})` };
|
||||||
} catch (error) { throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); }
|
} catch (error) {
|
||||||
|
// لا نبتلع أخطاء التحقق (400) ونحولها إلى 500
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertPlace(data: Partial<BasePlace>) {
|
async upsertPlace(data: Partial<BasePlace>) {
|
||||||
try {
|
try {
|
||||||
const lat = Number(data.latitude), lng = Number(data.longitude);
|
const { lat, lng } = this.extractCoords(data);
|
||||||
const repo = this.getRepositoryForCoords(lat, lng), tableName = this.getTableNameForRepo(repo);
|
const repo = this.getRepositoryForCoords(lat, lng), tableName = this.getTableNameForRepo(repo);
|
||||||
const existing = await repo.query(`SELECT id, name FROM ${tableName} WHERE ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) < 15 LIMIT 1`, [lng, lat]);
|
const existing = await repo.query(`SELECT id, name FROM ${tableName} WHERE ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) < 15 LIMIT 1`, [lng, lat]);
|
||||||
if (existing && existing.length > 0) {
|
if (existing && existing.length > 0) {
|
||||||
@@ -469,17 +587,48 @@ export class GeocodingService {
|
|||||||
} catch (error) { throw error; }
|
} catch (error) { throw error; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* الاستيراد الجملي (يستخدمه السكرابر). كان يبتلع كل خطأ بصمت
|
||||||
|
* (`catch (e) {}`) ويعيد عدد الناجحين فقط — فاستيراد 10 آلاف مكان يسقط منه
|
||||||
|
* 3 آلاف دون أثر ولا سبب. الآن نُعيد أول 50 خطأ مع رقم السطر.
|
||||||
|
*/
|
||||||
async upsertBatch(places: Partial<BasePlace>[]) {
|
async upsertBatch(places: Partial<BasePlace>[]) {
|
||||||
const results: any[] = [];
|
const results: any[] = [];
|
||||||
for (const place of places) { try { const r = await this.addPlace(place); results.push({ id: r.id, action: 'created' }); } catch (e) {} }
|
const errors: { index: number; name?: string; reason: string }[] = [];
|
||||||
return { total: places.length, processed: results.length, created: results.length, updated: 0 };
|
|
||||||
|
for (const [index, place] of places.entries()) {
|
||||||
|
try {
|
||||||
|
const r = await this.addPlace(place);
|
||||||
|
results.push({ id: r.id, action: 'created' });
|
||||||
|
} catch (e) {
|
||||||
|
if (errors.length < 50) {
|
||||||
|
errors.push({
|
||||||
|
index,
|
||||||
|
name: (place as any)?.name,
|
||||||
|
reason: e instanceof HttpException ? e.message : (e?.message ?? 'unknown'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = places.length - results.length;
|
||||||
|
return {
|
||||||
|
total: places.length,
|
||||||
|
processed: results.length,
|
||||||
|
created: results.length,
|
||||||
|
updated: 0,
|
||||||
|
failed,
|
||||||
|
errors,
|
||||||
|
errorsTruncated: failed > errors.length,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecentPlaces(limit: number = 50) {
|
async getRecentPlaces(limit: number = 50) {
|
||||||
const s = await this.placesSyriaRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
const s = await this.placesSyriaRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
||||||
const j = await this.placesJordanRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
const j = await this.placesJordanRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
||||||
const e = await this.placesEgyptRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
const e = await this.placesEgyptRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
||||||
return [...s, ...j, ...e].sort((a, b) => b.created_at.getTime() - a.created_at.getTime()).slice(0, limit);
|
const i = await this.placesIraqRepository.find({ order: { created_at: 'DESC' }, take: limit });
|
||||||
|
return [...s, ...j, ...e, ...i].sort((a, b) => b.created_at.getTime() - a.created_at.getTime()).slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllPlacesGeoJSON() {
|
async getAllPlacesGeoJSON() {
|
||||||
@@ -488,6 +637,7 @@ export class GeocodingService {
|
|||||||
SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_syria
|
SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_syria
|
||||||
UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_jordan
|
UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_jordan
|
||||||
UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_egypt
|
UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_egypt
|
||||||
|
UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_iraq
|
||||||
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Building') as name, 'building' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_building WHERE names->>'primary' IS NOT NULL LIMIT 500
|
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Building') as name, 'building' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_building WHERE names->>'primary' IS NOT NULL LIMIT 500
|
||||||
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Street') as name, 'street' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_segment WHERE names->>'primary' IS NOT NULL LIMIT 500
|
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Street') as name, 'street' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_segment WHERE names->>'primary' IS NOT NULL LIMIT 500
|
||||||
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Place') as name, 'place' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_place WHERE names->>'primary' IS NOT NULL LIMIT 500
|
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Place') as name, 'place' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_place WHERE names->>'primary' IS NOT NULL LIMIT 500
|
||||||
@@ -520,6 +670,7 @@ export class GeocodingService {
|
|||||||
case 'syria': return this.placesSyriaRepository;
|
case 'syria': return this.placesSyriaRepository;
|
||||||
case 'jordan': return this.placesJordanRepository;
|
case 'jordan': return this.placesJordanRepository;
|
||||||
case 'egypt': return this.placesEgyptRepository;
|
case 'egypt': return this.placesEgyptRepository;
|
||||||
|
case 'iraq': return this.placesIraqRepository;
|
||||||
default: throw new HttpException('Invalid country: ' + country, HttpStatus.BAD_REQUEST);
|
default: throw new HttpException('Invalid country: ' + country, HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { MapCandidate, CandidateStatus, CountryCode } from './entities/map-candi
|
|||||||
import { PlaceJordan } from './entities/place-jordan.entity';
|
import { PlaceJordan } from './entities/place-jordan.entity';
|
||||||
import { PlaceSyria } from './entities/place-syria.entity';
|
import { PlaceSyria } from './entities/place-syria.entity';
|
||||||
import { PlaceEgypt } from './entities/place-egypt.entity';
|
import { PlaceEgypt } from './entities/place-egypt.entity';
|
||||||
|
import { PlaceIraq } from './entities/place-iraq.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MapRefinementService {
|
export class MapRefinementService {
|
||||||
@@ -20,6 +21,8 @@ export class MapRefinementService {
|
|||||||
private syriaRepository: Repository<PlaceSyria>,
|
private syriaRepository: Repository<PlaceSyria>,
|
||||||
@InjectRepository(PlaceEgypt)
|
@InjectRepository(PlaceEgypt)
|
||||||
private egyptRepository: Repository<PlaceEgypt>,
|
private egyptRepository: Repository<PlaceEgypt>,
|
||||||
|
@InjectRepository(PlaceIraq)
|
||||||
|
private iraqRepository: Repository<PlaceIraq>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async suggestPlace(dto: any, submittedBy: string): Promise<MapCandidate> {
|
async suggestPlace(dto: any, submittedBy: string): Promise<MapCandidate> {
|
||||||
@@ -194,6 +197,7 @@ export class MapRefinementService {
|
|||||||
switch (candidate.country) {
|
switch (candidate.country) {
|
||||||
case CountryCode.SYRIA: repo = this.syriaRepository; break;
|
case CountryCode.SYRIA: repo = this.syriaRepository; break;
|
||||||
case CountryCode.EGYPT: repo = this.egyptRepository; break;
|
case CountryCode.EGYPT: repo = this.egyptRepository; break;
|
||||||
|
case CountryCode.IRAQ: repo = this.iraqRepository; break;
|
||||||
default: repo = this.jordanRepository;
|
default: repo = this.jordanRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+819
-169
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import maplibregl from 'maplibre-gl';
|
import maplibregl from 'maplibre-gl';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
import { attachIconLoader } from '../utils/mapIcons';
|
||||||
interface MapComponentProps {
|
interface MapComponentProps {
|
||||||
onMapLoad: (map: maplibregl.Map) => void;
|
onMapLoad: (map: maplibregl.Map) => void;
|
||||||
onMapClick?: (lat: number, lng: number) => void;
|
onMapClick?: (lat: number, lng: number) => void;
|
||||||
@@ -59,7 +60,6 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
|||||||
style: {
|
style: {
|
||||||
version: 8,
|
version: 8,
|
||||||
glyphs: 'https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf',
|
glyphs: 'https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf',
|
||||||
sprite: 'https://demotiles.maplibre.org/styles/osm-bright-gl-style/sprite',
|
|
||||||
sources: {
|
sources: {
|
||||||
'local-osm-polygons': {
|
'local-osm-polygons': {
|
||||||
type: 'vector',
|
type: 'vector',
|
||||||
@@ -390,6 +390,8 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
map.current = initialMap;
|
map.current = initialMap;
|
||||||
|
// آيقونات الـ POI تُحمّل عند الطلب بدل sprite مستضاف
|
||||||
|
attachIconLoader(initialMap);
|
||||||
|
|
||||||
// Request User Location
|
// Request User Location
|
||||||
if ('geolocation' in navigator) {
|
if ('geolocation' in navigator) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import maplibregl from 'maplibre-gl';
|
import maplibregl from 'maplibre-gl';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
import { attachIconLoader } from '../utils/mapIcons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CompareView — side-by-side, pan/zoom-synced map comparison.
|
* CompareView — side-by-side, pan/zoom-synced map comparison.
|
||||||
@@ -55,6 +56,8 @@ const CompareView: React.FC = () => {
|
|||||||
// already point at the absolute Martin host), so load it relatively — never
|
// already point at the absolute Martin host), so load it relatively — never
|
||||||
// prefixed with the tile host, which does not serve /style.json.
|
// prefixed with the tile host, which does not serve /style.json.
|
||||||
const lMap = new maplibregl.Map({ container: leftDiv.current, style: '/style.json', center: start, zoom: startZoom, attributionControl: false });
|
const lMap = new maplibregl.Map({ container: leftDiv.current, style: '/style.json', center: start, zoom: startZoom, attributionControl: false });
|
||||||
|
// آيقونات الـ POI تُحمّل عند الطلب — يجب الربط قبل أول رسم للطبقات
|
||||||
|
attachIconLoader(lMap);
|
||||||
const rMap = new maplibregl.Map({ container: rightDiv.current, style: rasterStyle(REFS['esri-sat']), center: start, zoom: startZoom, attributionControl: false });
|
const rMap = new maplibregl.Map({ container: rightDiv.current, style: rasterStyle(REFS['esri-sat']), center: start, zoom: startZoom, attributionControl: false });
|
||||||
lMap.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-left');
|
lMap.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-left');
|
||||||
rMap.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
|
rMap.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Map as MlMap } from 'maplibre-gl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تحميل آيقونات الـ POI عند الطلب من ملفات SVG في /icons.
|
||||||
|
*
|
||||||
|
* لماذا لا sprite: الستايل كان يشير إلى demotiles.maplibre.org (خادم MapLibre
|
||||||
|
* التجريبي). حين يفشل تحميله يرسم MapLibre نص التسمية بلا آيقونة وبصمت — فتظهر
|
||||||
|
* الخريطة بأسماء بلا رموز. البديل المعتاد هو بناء sprite sheet، لكنه يتطلب
|
||||||
|
* سلسلة أدوات (cairo/resvg) واستضافة ملف إضافي ومزامنته مع كل آيقونة جديدة.
|
||||||
|
*
|
||||||
|
* `styleimagemissing` ينطلق مرة واحدة لكل آيقونة تطلبها طبقة ولا يجدها المحرك،
|
||||||
|
* فنحمّلها وقتها من الـ SVG الموجود أصلاً في المستودع. لا خطوة بناء، ولا ملف
|
||||||
|
* يُستضاف، وإضافة آيقونة جديدة = إسقاط ملف SVG في المجلد فقط.
|
||||||
|
*
|
||||||
|
* ALIASES تسدّ الفجوة بين ما تطلبه طبقات الستايل وما هو متوفر فعلاً كملف.
|
||||||
|
*/
|
||||||
|
const ALIASES: Record<string, string> = {
|
||||||
|
rail: 'train',
|
||||||
|
college: 'tourist',
|
||||||
|
school: 'tourist',
|
||||||
|
cafe: 'cafe',
|
||||||
|
restaurant: 'restaurant',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZE = 24;
|
||||||
|
|
||||||
|
export function attachIconLoader(map: MlMap): () => void {
|
||||||
|
const pending = new Set<string>();
|
||||||
|
|
||||||
|
const onMissing = (e: { id: string }) => {
|
||||||
|
const id = e.id;
|
||||||
|
// الحدث قد يتكرر لنفس الآيقونة قبل اكتمال التحميل غير المتزامن
|
||||||
|
if (!id || pending.has(id) || map.hasImage(id)) return;
|
||||||
|
pending.add(id);
|
||||||
|
|
||||||
|
const file = ALIASES[id] ?? id;
|
||||||
|
const img = new Image(SIZE, SIZE);
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
// الفحص مكرر عمداً: التحميل غير متزامن وقد يكون الستايل تبدّل أثناءه
|
||||||
|
if (!map.hasImage(id)) {
|
||||||
|
try {
|
||||||
|
map.addImage(id, img, { pixelRatio: 1 });
|
||||||
|
} catch {
|
||||||
|
/* الستايل تبدّل تحتنا — تُطلب مجدداً عند الحاجة */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending.delete(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
// بلا ملف مقابل: نضيف بكسلاً شفافاً حتى لا يعيد MapLibre إطلاق الحدث
|
||||||
|
// في كل إطار. النص يظهر بلا رمز، وهو أفضل من حلقة لا تنتهي.
|
||||||
|
if (!map.hasImage(id)) {
|
||||||
|
map.addImage(id, { width: 1, height: 1, data: new Uint8Array(4) });
|
||||||
|
}
|
||||||
|
pending.delete(id);
|
||||||
|
console.warn(`[mapIcons] لا يوجد /icons/${file}.svg للآيقونة "${id}"`);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = `/icons/${file}.svg`;
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on('styleimagemissing', onMissing);
|
||||||
|
return () => map.off('styleimagemissing', onMissing);
|
||||||
|
}
|
||||||
+5
-2
@@ -46,11 +46,14 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8989:8080"
|
- "8989:8080"
|
||||||
environment:
|
environment:
|
||||||
- JAVA_OPTS=-Xmx8g -Xms1g
|
# 7.8GB إجمالي على السيرفر. باقي الحاويات ~1.3GB + النظام ~0.5GB.
|
||||||
|
# Xmx5g كان ينتج RSS ~6GB فيصطدم بالسقف ويُقتل بالـ OOM لحظة عودة باقي
|
||||||
|
# الحاويات — رغم أن البناء نفسه ينجح حين تكون موقوفة. 4g تترك هامشاً حقيقياً.
|
||||||
|
- JAVA_OPTS=-Xmx4g -Xms1g
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 10g
|
memory: 6g
|
||||||
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
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# اتجاهات السير في محرك التوجيه — قاعدة لا تُكسر
|
||||||
|
|
||||||
|
> سجلّ عطل حقيقي كلّف يومين (26–28 يوليو 2026). اقرأ هذا قبل أي تعديل على
|
||||||
|
> `infrastructure/docker/graphhopper/config.yml`.
|
||||||
|
|
||||||
|
## القاعدة
|
||||||
|
|
||||||
|
`custom_model` المكتوب **سطرياً** داخل `profiles` لا يرث القواعد الافتراضية
|
||||||
|
لملف `car.json`. أهم قاعدة ساقطة هي التي تفرض اتجاه السير:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- if: "!car_access"
|
||||||
|
multiply_by: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
بدونها تبقى `car_access` مخزّنة في الغراف (تراها في اللوغ) لكنها **لا تدخل في
|
||||||
|
حساب الوزن إطلاقاً**. النتيجة: المحرك يتجاهل كل `oneway` وكل `junction=roundabout`
|
||||||
|
ويمشي في الاتجاهين على كل شارع — يبدو للمستخدم «كأنه يمشي كالمشاة».
|
||||||
|
|
||||||
|
القاعدة تتطلّب وجود `car_access` ضمن `graph.encoded_values`، وإلا يرفض المحرك
|
||||||
|
الإقلاع بـ `Encoded values missing: car_access`.
|
||||||
|
|
||||||
|
**السطران الإلزاميان في config.yml:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
graph.encoded_values: ...,car_access # car_access إلزامية
|
||||||
|
|
||||||
|
custom_model:
|
||||||
|
priority:
|
||||||
|
- if: "!car_access" # أول قاعدة في priority
|
||||||
|
multiply_by: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## كيف يبدو العطل
|
||||||
|
|
||||||
|
| العرض | الملاحظة |
|
||||||
|
|---|---|
|
||||||
|
| `A→B` و `B→A` بنفس المسافة تماماً على شارع أحادي | البصمة القاطعة |
|
||||||
|
| المسار يقطع دواراً مستقيماً بلا تعليمة `sign: 6` | حلقات الدوار أحادية ضمناً، فتُفتح بالاتجاهين |
|
||||||
|
| `details=car_access` يُظهر `False` على مقطع ضمن مسار مقبول | المحرك مرّ عبر حافة ممنوعة |
|
||||||
|
|
||||||
|
## تشخيص سريع
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash infrastructure/scripts/verify-oneway.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
الفحص اليدوي لمسار بعينه:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "http://localhost:8989/route?point=LAT1,LON1&point=LAT2,LON2&profile=car&details=car_access&details=roundabout" | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚠️ تحذير: check-bidirectional.sh منطقه معكوس
|
||||||
|
|
||||||
|
`check-bidirectional.sh` يعتبر **اختلاف** الذهاب عن العودة فشلاً، وينصح بتشغيل
|
||||||
|
`normalize-oneway.sh`. هذا خطأ: الاختلاف على شارع أحادي هو السلوك **الصحيح**.
|
||||||
|
لا تتبع نصيحته، ولا تشغّل `normalize-oneway.sh apply` إلا بعد التحقق ميدانياً
|
||||||
|
من أن الشارع ثنائي الاتجاه فعلاً على الأرض.
|
||||||
|
|
||||||
|
`normalize_oneway.py` يكتب `oneway=no` على البيانات نفسها. تعديل البيانات لإخفاء
|
||||||
|
عرَض ناتج عن خطأ إعداد يُنتج مسارات غير قانونية للسائقين.
|
||||||
|
|
||||||
|
## متى يكون السبب البيانات لا الإعداد
|
||||||
|
|
||||||
|
إن كان `verify-oneway.sh` ناجحاً وما زال مسار بعينه خاطئاً، فالنقص في OSM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
osmium extract -b LON1,LAT1,LON2,LAT2 infrastructure/osm-data/master_map.osm.pbf -o /tmp/a.pbf --overwrite
|
||||||
|
osmium cat -f opl /tmp/a.pbf | grep '^w' | grep -c 'junction=roundabout'
|
||||||
|
```
|
||||||
|
|
||||||
|
صفر = الدوار غير مرسوم. الحل: ارسمه على openstreetmap.org — يصل تلقائياً في
|
||||||
|
دورة التحديث القادمة ويفيد الجميع. البديل الموضعي هو `approved_roads` عبر
|
||||||
|
`apply-delta.sh`، لكنه يحتاج صيانة يدوية لكل حالة.
|
||||||
|
|
||||||
|
## الصورة
|
||||||
|
|
||||||
|
نستخدم GraphHopper 11.0 الرسمي مبنياً محلياً من
|
||||||
|
[`infrastructure/docker/graphhopper/Dockerfile`](../infrastructure/docker/graphhopper/Dockerfile).
|
||||||
|
|
||||||
|
الصورة السابقة `israelhikingmap/graphhopper` كانت تتجاهل `graph.location`
|
||||||
|
(تبني في `/data/default-gh`) وتتجاهل `JAVA_OPTS`. لا تعُد إليها.
|
||||||
|
|
||||||
|
`ENTRYPOINT` يستخدم `sh -c` عمداً — الصيغة exec المباشرة لا توسّع `$JAVA_OPTS`
|
||||||
|
فيبقى الـ heap على الافتراضي.
|
||||||
|
|
||||||
|
## الطرق المرسومة (approved_roads)
|
||||||
|
|
||||||
|
`update-data.sh` يستدعي `apply-delta.sh` بعد كل دمج، فيصدّر `approved_roads`
|
||||||
|
كـ OSM XML ويدمجها في `master_map.osm.pbf`. الطرق المرسومة تنجو من كل تحديث.
|
||||||
|
|
||||||
|
الاتصال بالشبكة يعتمد على `start_node` / `end_node` (معرّفات OSM حقيقية تُحلّ
|
||||||
|
وقت الموافقة). بدونهما تُنشأ عقد جديدة بمعرّفات سالبة ويصبح الطريق **معزولاً** —
|
||||||
|
موجود في الخريطة لكن لا يمر به التوجيه.
|
||||||
|
|
||||||
|
`oneway` يُصدَّر أيضاً: `1` → `yes`، `-1` → `-1`. أي أن الاتجاه المرسوم محترم،
|
||||||
|
شرط أن تكون قاعدة `!car_access` أعلاه موجودة.
|
||||||
|
|
||||||
|
## بعد أي تعديل على config.yml
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose stop routing
|
||||||
|
rm -rf infrastructure/osm-data/graph-cache infrastructure/osm-data/default-gh
|
||||||
|
docker compose up -d routing && docker compose logs -f routing # انتظر "Started Server"
|
||||||
|
bash infrastructure/scripts/verify-oneway.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
البناء ~4 دقائق. إن ظهر `exited with code 137` فهو OOM — أوقف
|
||||||
|
`api web dashboard martin redis` أثناء البناء، أو أنزل `-Xmx` في docker-compose.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
build-sprite.py — يبني sprite sheet لـ MapLibre من مجلد SVG.
|
||||||
|
|
||||||
|
لماذا: MapLibre لا يقرأ ملفات SVG مفردة. يحتاج صورة PNG واحدة تُرصف فيها كل
|
||||||
|
الآيقونات + فهرس JSON يحدد موضع وحجم كل واحدة. كان الستايل يشير إلى
|
||||||
|
demotiles.maplibre.org (خادم تجريبي)، وحين يفشل تحميله يرسم MapLibre النص
|
||||||
|
بلا آيقونة وبصمت — فتبدو الخريطة بأسماء بلا رموز.
|
||||||
|
|
||||||
|
الاستخدام:
|
||||||
|
pip install cairosvg pillow
|
||||||
|
python3 infrastructure/scripts/build-sprite.py
|
||||||
|
|
||||||
|
المخرجات في apps/web/public/:
|
||||||
|
sprite.png / sprite.json (1x)
|
||||||
|
sprite@2x.png / sprite@2x.json (شاشات Retina — MapLibre يطلبها تلقائياً)
|
||||||
|
|
||||||
|
ثم في الستايل: "sprite": "https://map-saas.intaleqapp.com/sprite"
|
||||||
|
(بلا امتداد — MapLibre يضيف .png/.json و@2x بنفسه)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cairosvg
|
||||||
|
from PIL import Image
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("ينقص: pip install cairosvg pillow")
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
ICONS_DIR = os.path.join(ROOT, "apps", "web", "public", "icons")
|
||||||
|
OUT_DIR = os.path.join(ROOT, "apps", "web", "public")
|
||||||
|
|
||||||
|
BASE_SIZE = 24 # بكسل للآيقونة عند 1x
|
||||||
|
PADDING = 2 # فاصل يمنع نزّ ألوان الجيران عند التصغير (bleeding)
|
||||||
|
|
||||||
|
|
||||||
|
def build(scale: int, suffix: str) -> None:
|
||||||
|
size = BASE_SIZE * scale
|
||||||
|
pad = PADDING * scale
|
||||||
|
|
||||||
|
svgs = sorted(f for f in os.listdir(ICONS_DIR) if f.endswith(".svg"))
|
||||||
|
if not svgs:
|
||||||
|
sys.exit(f"لا توجد ملفات SVG في {ICONS_DIR}")
|
||||||
|
|
||||||
|
rendered = []
|
||||||
|
for fname in svgs:
|
||||||
|
name = os.path.splitext(fname)[0]
|
||||||
|
png_bytes = cairosvg.svg2png(
|
||||||
|
url=os.path.join(ICONS_DIR, fname),
|
||||||
|
output_width=size,
|
||||||
|
output_height=size,
|
||||||
|
)
|
||||||
|
import io
|
||||||
|
rendered.append((name, Image.open(io.BytesIO(png_bytes)).convert("RGBA")))
|
||||||
|
|
||||||
|
# شبكة مربعة تقريباً: أقل هدراً من صف واحد طويل
|
||||||
|
cols = max(1, int(len(rendered) ** 0.5 + 0.999))
|
||||||
|
rows = (len(rendered) + cols - 1) // cols
|
||||||
|
|
||||||
|
sheet = Image.new(
|
||||||
|
"RGBA",
|
||||||
|
(cols * (size + pad) - pad, rows * (size + pad) - pad),
|
||||||
|
(0, 0, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
index = {}
|
||||||
|
for i, (name, img) in enumerate(rendered):
|
||||||
|
x = (i % cols) * (size + pad)
|
||||||
|
y = (i // cols) * (size + pad)
|
||||||
|
sheet.paste(img, (x, y))
|
||||||
|
index[name] = {
|
||||||
|
"x": x, "y": y,
|
||||||
|
"width": size, "height": size,
|
||||||
|
"pixelRatio": scale,
|
||||||
|
"sdf": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
sheet.save(os.path.join(OUT_DIR, f"sprite{suffix}.png"))
|
||||||
|
with open(os.path.join(OUT_DIR, f"sprite{suffix}.json"), "w") as fh:
|
||||||
|
json.dump(index, fh, indent=2)
|
||||||
|
|
||||||
|
print(f"✅ sprite{suffix}: {len(index)} آيقونة، {sheet.width}×{sheet.height}px")
|
||||||
|
return list(index)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
names = build(1, "")
|
||||||
|
build(2, "@2x")
|
||||||
|
print("\nالآيقونات المتاحة لـ icon-image:")
|
||||||
|
print(" " + ", ".join(names))
|
||||||
|
print("\n⚠️ طبقات الستايل تطلب أسماء قد لا تكون موجودة أعلاه")
|
||||||
|
print(" (college, rail, tourist ...) — راجعها أو أضف SVG مقابلاً لكل ناقص.")
|
||||||
@@ -56,8 +56,11 @@ for c in "${CASES[@]}"; do
|
|||||||
if [ "$1" = "ok" ]; then
|
if [ "$1" = "ok" ]; then
|
||||||
echo " ✓ متقارب (نسبة ${2})"
|
echo " ✓ متقارب (نسبة ${2})"
|
||||||
else
|
else
|
||||||
echo " ✗ فارق كبير (نسبة ${2:-؟}) — اتجاه واحد مفروض أو التفاف إجباري"
|
echo " ⚠ فارق كبير (نسبة ${2:-؟}) — اتجاه واحد مفروض أو التفاف إجباري"
|
||||||
echo " شغّل: bash infrastructure/scripts/normalize-oneway.sh audit"
|
echo " هذا ليس عطلاً بالضرورة: على شارع أحادي فعلاً هذا هو السلوك الصحيح."
|
||||||
|
echo " تحقّق ميدانياً أولاً. لا تشغّل normalize-oneway.sh apply قبل ذلك —"
|
||||||
|
echo " فهو يكتب oneway=no على البيانات وينتج مسارات غير قانونية."
|
||||||
|
echo " اقرأ: docs/ROUTING_ONEWAY_AR.md"
|
||||||
FAIL=1
|
FAIL=1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -1,82 +1,170 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# overture_ingest.sh — سحب وحقن بيانات Overture (مباني، شوارع، أماكن، حدود)
|
||||||
|
#
|
||||||
|
# ⚠️ تغيير جوهري عن النسخة القديمة:
|
||||||
|
# القديمة كانت تبدأ بـ DROP TABLE overture_place, overture_building,
|
||||||
|
# overture_segment — أي أن تشغيلها لأي دولة يمسح بيانات كل الدول الأخرى.
|
||||||
|
# الآن الحذف *محصور بالنطاق الجغرافي* الجاري إعادة حقنه فقط، فتبقى بيانات
|
||||||
|
# بقية الدول سليمة. هذا يجعل السكربت قابلاً للتشغيل الجزئي وإعادة التشغيل.
|
||||||
|
#
|
||||||
|
# الاستخدام:
|
||||||
|
# bash infrastructure/scripts/overture_ingest.sh # كل النطاقات
|
||||||
|
# bash infrastructure/scripts/overture_ingest.sh baghdad # نطاق واحد
|
||||||
|
# bash infrastructure/scripts/overture_ingest.sh iraq baghdad # عدة نطاقات
|
||||||
|
#
|
||||||
|
# ملاحظة عن الأحجام: المباني تُسحب على مستوى المدينة لا الدولة — مباني دولة
|
||||||
|
# كاملة قد تتجاوز عدة جيجابايت GeoJSON ولا يحتملها قرص السيرفر الحالي.
|
||||||
|
# لذلك: building/segment ← نطاق مدينة، place/division_area ← نطاق دولة.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
# overture_ingest.sh - Data pipeline for Overture Maps
|
APP_DIR="${APP_DIR:-/home/hamzadoctor/app}"
|
||||||
# سكريبت سحب وحقن بيانات المباني والشوارع
|
DB_HOST="${DB_HOST:-localhost}"
|
||||||
|
DB_PORT="${DB_PORT:-5432}"
|
||||||
|
DB_USER="${DB_USER:-mapuser}"
|
||||||
|
DB_NAME="${DB_NAME:-mapdb}"
|
||||||
|
DB_PASS="${DB_PASS:-mappass}"
|
||||||
|
|
||||||
set -e
|
cd "$APP_DIR"
|
||||||
|
|
||||||
# Configuration
|
# ── النطاقات: "minlon,minlat,maxlon,maxlat" ───────────────────────────────
|
||||||
DB_HOST="localhost"
|
# نطاقات الدول (للأماكن والحدود الإدارية)
|
||||||
DB_PORT="5432"
|
|
||||||
DB_USER="mapuser"
|
|
||||||
DB_NAME="mapdb"
|
|
||||||
DB_PASS="mappass"
|
|
||||||
|
|
||||||
# Jordan North BBOX (Amman, Irbid, Zarqa)
|
|
||||||
BBOX_JORDAN_NORTH="35.5,31.7,39.3,33.4"
|
BBOX_JORDAN_NORTH="35.5,31.7,39.3,33.4"
|
||||||
# Jordan South BBOX (Aqaba, Karak, Ma'an)
|
|
||||||
BBOX_JORDAN_SOUTH="34.9,29.1,38.0,31.7"
|
BBOX_JORDAN_SOUTH="34.9,29.1,38.0,31.7"
|
||||||
# Syria BBOX (Full Country)
|
|
||||||
BBOX_SYRIA="35.7,32.3,42.4,37.3"
|
BBOX_SYRIA="35.7,32.3,42.4,37.3"
|
||||||
|
BBOX_EGYPT="24.7,21.9,36.9,31.7"
|
||||||
|
BBOX_IRAQ="38.7,29.0,48.8,37.4"
|
||||||
|
|
||||||
echo "🚀 Starting Overture Data Pipeline (Jordan & Syria)..."
|
# نطاقات المدن (للمباني والشوارع — أثقل بكثير)
|
||||||
|
BBOX_AMMAN="35.75,31.75,36.15,32.15"
|
||||||
|
BBOX_DAMASCUS="36.15,33.40,36.45,33.62"
|
||||||
|
BBOX_CAIRO="31.10,29.85,31.50,30.20"
|
||||||
|
# بغداد المركز فقط (~25×17كم). النطاق الأوسع 44.10,33.10,44.70,33.50 تجاوز
|
||||||
|
# 632 ألف مبنى وملأ قرص السيرفر قبل أن ينتهي. وسّعه بعد ترقية التخزين.
|
||||||
|
BBOX_BAGHDAD="44.28,33.25,44.55,33.40"
|
||||||
|
|
||||||
echo "🧹 Clearing old Overture tables to prevent duplication..."
|
psql_run() {
|
||||||
docker compose -f /home/hamzadoctor/app/docker-compose.yml exec -T db psql -U $DB_USER -d $DB_NAME -c "DROP TABLE IF EXISTS overture_place, overture_building, overture_segment;"
|
docker compose -f "${APP_DIR}/docker-compose.yml" exec -T db \
|
||||||
|
psql -U "$DB_USER" -d "$DB_NAME" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
# 1. Setup Python Environment
|
# ── حذف محصور بالنطاق: ينظّف ما سنعيد حقنه فقط، ولا يمس الدول الأخرى ──────
|
||||||
# ... (rest of setup)
|
purge_bbox() {
|
||||||
if [ ! -d "venv_overture" ]; then
|
local table=$1 bbox=$2
|
||||||
echo "📦 Creating virtual environment..."
|
IFS=',' read -r minx miny maxx maxy <<< "$bbox"
|
||||||
python3 -m venv venv_overture
|
# to_regclass يمنع الفشل في أول تشغيل قبل أن ينشئ ogr2ogr الجدول
|
||||||
fi
|
psql_run -c "
|
||||||
source venv_overture/bin/activate
|
DO \$\$
|
||||||
pip install --upgrade pip
|
BEGIN
|
||||||
pip install overturemaps
|
IF to_regclass('public.${table}') IS NOT NULL THEN
|
||||||
|
DELETE FROM ${table}
|
||||||
|
WHERE location && ST_MakeEnvelope(${minx}, ${miny}, ${maxx}, ${maxy}, 4326);
|
||||||
|
END IF;
|
||||||
|
END \$\$;
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
# 2. Function to download and ingest
|
process() {
|
||||||
process_city() {
|
local region=$1 bbox=$2 theme=$3
|
||||||
local city=$1
|
local output_file="overture_${region}_${theme}.geojson"
|
||||||
local bbox=$2
|
|
||||||
local theme=$3 # building, segment, or division_area
|
|
||||||
|
|
||||||
echo "🌍 Processing $city - Theme: $theme..."
|
echo "🌍 [$region] السمة: $theme ..."
|
||||||
output_file="overture_${city}_${theme}.geojson"
|
|
||||||
|
|
||||||
# Download
|
|
||||||
overturemaps download --bbox="$bbox" -f geojson --type="$theme" -o "$output_file"
|
overturemaps download --bbox="$bbox" -f geojson --type="$theme" -o "$output_file"
|
||||||
|
|
||||||
# If division_area, we use our NestJS API to import for better control
|
|
||||||
if [ "$theme" == "division_area" ]; then
|
if [ "$theme" == "division_area" ]; then
|
||||||
echo "📍 Administrative data downloaded: $output_file"
|
echo "📍 الحدود الإدارية جاهزة: $output_file"
|
||||||
echo "💡 Use the NestJS /geocoding/import-boundaries endpoint to ingest this file."
|
echo " استوردها عبر: POST /geocoding/import-boundaries?country=<CODE>&filePath=/data/${output_file}"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ingest into PostGIS for buildings and segments
|
echo "🧹 تنظيف نطاق ${region} من overture_${theme} (بدون المساس بغيره)..."
|
||||||
echo "🔌 Injecting into PostGIS (Table: overture_${theme})..."
|
purge_bbox "overture_${theme}" "$bbox"
|
||||||
|
|
||||||
|
echo "🔌 الحقن في PostGIS (overture_${theme})..."
|
||||||
export PGPASSWORD=$DB_PASS
|
export PGPASSWORD=$DB_PASS
|
||||||
ogr2ogr -f "PostgreSQL" \
|
# `if !` ضروري: مع set -e يخرج السكربت فوراً عند فشل ogr2ogr فلا يُحذف
|
||||||
|
# الملف المؤقت. ملف مباني مدينة كبيرة قد يبلغ عدة جيجابايت، وتَركه بعد
|
||||||
|
# فشل سببه امتلاء القرص أصلاً يجعل المحاولة التالية تفشل أسرع.
|
||||||
|
if ! ogr2ogr -f "PostgreSQL" \
|
||||||
PG:"host=$DB_HOST port=$DB_PORT user=$DB_USER dbname=$DB_NAME password=$DB_PASS" \
|
PG:"host=$DB_HOST port=$DB_PORT user=$DB_USER dbname=$DB_NAME password=$DB_PASS" \
|
||||||
"$output_file" \
|
"$output_file" \
|
||||||
-nln "overture_${theme}" \
|
-nln "overture_${theme}" \
|
||||||
-update -append \
|
-update -append \
|
||||||
-nlt PROMOTE_TO_MULTI \
|
-nlt PROMOTE_TO_MULTI \
|
||||||
-lco GEOMETRY_NAME=location
|
-lco GEOMETRY_NAME=location
|
||||||
|
then
|
||||||
|
echo "❌ فشل حقن ${region}/${theme} — يُحذف الملف المؤقت ($(du -h "$output_file" 2>/dev/null | cut -f1))"
|
||||||
|
rm -f "$output_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "✅ Finished $city $theme"
|
echo "✅ انتهى ${region}/${theme}"
|
||||||
rm "$output_file"
|
rm -f "$output_file"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 3. Execution (Jordan & Syria)
|
# ── تجهيز بيئة بايثون ─────────────────────────────────────────────────────
|
||||||
# Jordan
|
if [ ! -d "venv_overture" ]; then
|
||||||
process_city "jordan_north" "$BBOX_JORDAN_NORTH" "division_area"
|
echo "📦 إنشاء بيئة افتراضية..."
|
||||||
process_city "jordan_north" "$BBOX_JORDAN_NORTH" "place"
|
python3 -m venv venv_overture
|
||||||
process_city "jordan_south" "$BBOX_JORDAN_SOUTH" "division_area"
|
fi
|
||||||
process_city "jordan_south" "$BBOX_JORDAN_SOUTH" "place"
|
source venv_overture/bin/activate
|
||||||
|
pip install --quiet --upgrade pip
|
||||||
|
pip install --quiet overturemaps
|
||||||
|
|
||||||
# Syria
|
# ── تعريف النطاقات القابلة للتشغيل ────────────────────────────────────────
|
||||||
process_city "syria" "$BBOX_SYRIA" "division_area"
|
run_jordan() { process jordan_north "$BBOX_JORDAN_NORTH" division_area
|
||||||
process_city "syria" "$BBOX_SYRIA" "place"
|
process jordan_north "$BBOX_JORDAN_NORTH" place
|
||||||
|
process jordan_south "$BBOX_JORDAN_SOUTH" division_area
|
||||||
|
process jordan_south "$BBOX_JORDAN_SOUTH" place; }
|
||||||
|
run_syria() { process syria "$BBOX_SYRIA" division_area
|
||||||
|
process syria "$BBOX_SYRIA" place; }
|
||||||
|
run_egypt() { process egypt "$BBOX_EGYPT" division_area
|
||||||
|
process egypt "$BBOX_EGYPT" place; }
|
||||||
|
run_iraq() { process iraq "$BBOX_IRAQ" division_area
|
||||||
|
process iraq "$BBOX_IRAQ" place; }
|
||||||
|
|
||||||
echo "🎉 Administrative boundary GeoJSONs ready for import!"
|
run_amman() { process amman "$BBOX_AMMAN" building
|
||||||
|
process amman "$BBOX_AMMAN" segment; }
|
||||||
|
run_damascus() { process damascus "$BBOX_DAMASCUS" building
|
||||||
|
process damascus "$BBOX_DAMASCUS" segment; }
|
||||||
|
run_cairo() { process cairo "$BBOX_CAIRO" building
|
||||||
|
process cairo "$BBOX_CAIRO" segment; }
|
||||||
|
run_baghdad() { process baghdad "$BBOX_BAGHDAD" building
|
||||||
|
process baghdad "$BBOX_BAGHDAD" segment; }
|
||||||
|
|
||||||
|
ALL_REGIONS=(jordan syria egypt iraq amman damascus cairo baghdad)
|
||||||
|
TARGETS=("$@")
|
||||||
|
if [ ${#TARGETS[@]} -eq 0 ]; then
|
||||||
|
TARGETS=("${ALL_REGIONS[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 نطاقات هذا التشغيل: ${TARGETS[*]}"
|
||||||
|
for r in "${TARGETS[@]}"; do
|
||||||
|
if ! declare -F "run_${r}" >/dev/null; then
|
||||||
|
echo "❌ نطاق غير معروف: ${r} — المتاح: ${ALL_REGIONS[*]}"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
"run_${r}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── فهارس مكانية + تسطيح الأسماء (idempotent) ─────────────────────────────
|
||||||
|
# name_primary إلزامي: عمود names من نوع json وبلاطات MVT لا تحمل كائنات، فلا
|
||||||
|
# يستطيع الستايل قراءة الاسم منه. التفاصيل: infrastructure/sql/09_overture_name_primary.sql
|
||||||
|
for t in overture_building overture_segment overture_place; do
|
||||||
|
psql_run -c "
|
||||||
|
DO \$\$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('public.${t}') IS NOT NULL THEN
|
||||||
|
EXECUTE 'CREATE INDEX IF NOT EXISTS idx_${t}_location ON ${t} USING gist (location)';
|
||||||
|
EXECUTE 'ALTER TABLE ${t} ADD COLUMN IF NOT EXISTS name_primary text';
|
||||||
|
EXECUTE 'UPDATE ${t} SET name_primary = names->>''primary''
|
||||||
|
WHERE names IS NOT NULL AND name_primary IS NULL';
|
||||||
|
EXECUTE 'CREATE INDEX IF NOT EXISTS idx_${t}_name_primary ON ${t} (name_primary)
|
||||||
|
WHERE name_primary IS NOT NULL';
|
||||||
|
END IF;
|
||||||
|
END \$\$;
|
||||||
|
"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "🎉 اكتمل. البلاطات تُخدم عبر martin تلقائياً (WATCH_DB=true)."
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
set -e # Exit on error
|
set -e # Exit on error
|
||||||
|
|
||||||
echo "🚀 Starting 10-day map update (v2 — Jordan + Syria + Egypt + Delta)..."
|
echo "🚀 Starting 10-day map update (v3 — Jordan + Syria + Egypt + Iraq + Delta)..."
|
||||||
|
|
||||||
APP_DIR="/home/hamzadoctor/app"
|
APP_DIR="/home/hamzadoctor/app"
|
||||||
DATA_DIR="${APP_DIR}/infrastructure/osm-data"
|
DATA_DIR="${APP_DIR}/infrastructure/osm-data"
|
||||||
@@ -21,27 +21,28 @@ DELTA_FILE="${DATA_DIR}/delta.osm"
|
|||||||
|
|
||||||
cd "${APP_DIR}"
|
cd "${APP_DIR}"
|
||||||
|
|
||||||
# ── Step 1: Download all three country PBFs ────────────────────────────────
|
# ── Step 1: Download all four country PBFs ─────────────────────────────────
|
||||||
echo "🌍 Downloading Jordan, Syria & Egypt PBF data from Geofabrik..."
|
echo "🌍 Downloading Jordan, Syria, Egypt & Iraq PBF data from Geofabrik..."
|
||||||
|
|
||||||
wget -q --show-progress -O "${DATA_DIR}/jordan-latest.osm.pbf.new" \
|
wget -q --show-progress -O "${DATA_DIR}/jordan-latest.osm.pbf.new" \
|
||||||
"https://download.geofabrik.de/asia/jordan-latest.osm.pbf"
|
"https://download.geofabrik.de/asia/jordan-latest.osm.pbf"
|
||||||
|
|
||||||
wget -q --show-progress -O "${DATA_DIR}/syria-latest.osm.pbf.new" \
|
# ✅ FIX: كان يحمّل مصر إلى ملف سوريا ثم يعيد تحميل سوريا فوقها — هدر 177MB كل دورة
|
||||||
"https://download.geofabrik.de/africa/egypt-latest.osm.pbf"
|
|
||||||
# ✅ FIX: Syria URL was accidentally downloading Egypt above in old script — corrected
|
|
||||||
wget -q --show-progress -O "${DATA_DIR}/egypt-latest.osm.pbf.new" \
|
|
||||||
"https://download.geofabrik.de/africa/egypt-latest.osm.pbf"
|
|
||||||
wget -q --show-progress -O "${DATA_DIR}/syria-latest.osm.pbf.new" \
|
wget -q --show-progress -O "${DATA_DIR}/syria-latest.osm.pbf.new" \
|
||||||
"https://download.geofabrik.de/asia/syria-latest.osm.pbf"
|
"https://download.geofabrik.de/asia/syria-latest.osm.pbf"
|
||||||
|
wget -q --show-progress -O "${DATA_DIR}/egypt-latest.osm.pbf.new" \
|
||||||
|
"https://download.geofabrik.de/africa/egypt-latest.osm.pbf"
|
||||||
|
wget -q --show-progress -O "${DATA_DIR}/iraq-latest.osm.pbf.new" \
|
||||||
|
"https://download.geofabrik.de/asia/iraq-latest.osm.pbf"
|
||||||
|
|
||||||
mv "${DATA_DIR}/jordan-latest.osm.pbf.new" "${DATA_DIR}/jordan-latest.osm.pbf"
|
mv "${DATA_DIR}/jordan-latest.osm.pbf.new" "${DATA_DIR}/jordan-latest.osm.pbf"
|
||||||
mv "${DATA_DIR}/syria-latest.osm.pbf.new" "${DATA_DIR}/syria-latest.osm.pbf"
|
mv "${DATA_DIR}/syria-latest.osm.pbf.new" "${DATA_DIR}/syria-latest.osm.pbf"
|
||||||
mv "${DATA_DIR}/egypt-latest.osm.pbf.new" "${DATA_DIR}/egypt-latest.osm.pbf"
|
mv "${DATA_DIR}/egypt-latest.osm.pbf.new" "${DATA_DIR}/egypt-latest.osm.pbf"
|
||||||
|
mv "${DATA_DIR}/iraq-latest.osm.pbf.new" "${DATA_DIR}/iraq-latest.osm.pbf"
|
||||||
|
|
||||||
echo "✅ Downloads complete."
|
echo "✅ Downloads complete."
|
||||||
|
|
||||||
# ── Step 2: Import all three countries into PostGIS ───────────────────────
|
# ── Step 2: Import all four countries into PostGIS ─────────────────────────
|
||||||
echo "💾 Importing Jordan into PostGIS (--create resets planet_osm_* tables)..."
|
echo "💾 Importing Jordan into PostGIS (--create resets planet_osm_* tables)..."
|
||||||
docker compose --profile import run --rm osm-import \
|
docker compose --profile import run --rm osm-import \
|
||||||
osm2pgsql --create --slim --cache 1000 \
|
osm2pgsql --create --slim --cache 1000 \
|
||||||
@@ -61,13 +62,30 @@ docker compose --profile import run --rm osm-import \
|
|||||||
--database mapdb --host db --user mapuser \
|
--database mapdb --host db --user mapuser \
|
||||||
/data/egypt-latest.osm.pbf
|
/data/egypt-latest.osm.pbf
|
||||||
|
|
||||||
# ── Step 3: Merge all three PBFs into master_map.osm.pbf ─────────────────
|
echo "💾 Importing Iraq into PostGIS (--append)..."
|
||||||
|
docker compose --profile import run --rm osm-import \
|
||||||
|
osm2pgsql --append --slim --cache 1000 \
|
||||||
|
--database mapdb --host db --user mapuser \
|
||||||
|
/data/iraq-latest.osm.pbf
|
||||||
|
|
||||||
|
# ── Step 2b: إسقاط جداول osm2pgsql الوسيطة ────────────────────────────────
|
||||||
|
# planet_osm_nodes/ways/rels ينشئها --slim لدعم --append فقط، ولا يقرأها
|
||||||
|
# التطبيق ولا martin إطلاقاً (جداول الإخراج هي line/polygon/point/roads).
|
||||||
|
# قياس 30/07/2026: nodes 5031MB + ways 1400MB ≈ 6.4GB نائمة بين الدورات.
|
||||||
|
# آمن لأن --create في الخطوة 2 يعيد إنشاءها من الصفر كل تشغيل.
|
||||||
|
# ⚠️ لا تشغّل osm2pgsql --append بعد هذه النقطة حتى الدورة القادمة.
|
||||||
|
echo "🧹 إسقاط جداول osm2pgsql الوسيطة (تُعاد في الدورة القادمة)..."
|
||||||
|
docker compose exec -T db psql -U mapuser -d mapdb -c \
|
||||||
|
"DROP TABLE IF EXISTS planet_osm_nodes, planet_osm_ways, planet_osm_rels;"
|
||||||
|
|
||||||
|
# ── Step 3: Merge all four PBFs into master_map.osm.pbf ────────────────────
|
||||||
# ✅ FIX: Old script wrote to region.osm.pbf — GraphHopper reads master_map.osm.pbf
|
# ✅ FIX: Old script wrote to region.osm.pbf — GraphHopper reads master_map.osm.pbf
|
||||||
echo "🗺️ Merging Jordan + Syria + Egypt → ${MASTER_FILE}..."
|
echo "🗺️ Merging Jordan + Syria + Egypt + Iraq → ${MASTER_FILE}..."
|
||||||
osmium merge \
|
osmium merge \
|
||||||
"${DATA_DIR}/jordan-latest.osm.pbf" \
|
"${DATA_DIR}/jordan-latest.osm.pbf" \
|
||||||
"${DATA_DIR}/syria-latest.osm.pbf" \
|
"${DATA_DIR}/syria-latest.osm.pbf" \
|
||||||
"${DATA_DIR}/egypt-latest.osm.pbf" \
|
"${DATA_DIR}/egypt-latest.osm.pbf" \
|
||||||
|
"${DATA_DIR}/iraq-latest.osm.pbf" \
|
||||||
-o "${MASTER_FILE}" --overwrite
|
-o "${MASTER_FILE}" --overwrite
|
||||||
|
|
||||||
echo "✅ Master PBF built: $(du -sh ${MASTER_FILE} | cut -f1)"
|
echo "✅ Master PBF built: $(du -sh ${MASTER_FILE} | cut -f1)"
|
||||||
@@ -92,16 +110,48 @@ docker compose exec -T db psql -U mapuser -d mapdb -t -c \
|
|||||||
echo "✅ Landmark sync done."
|
echo "✅ Landmark sync done."
|
||||||
|
|
||||||
# ── Step 6: Rebuild GraphHopper routing index ─────────────────────────────
|
# ── Step 6: Rebuild GraphHopper routing index ─────────────────────────────
|
||||||
|
# ذروة البناء لأربع دول ~3GB. على سيرفر بـ7.8GB الهامش ضيق، فنُخلي الذاكرة
|
||||||
|
# للحاويات غير الضرورية أثناء البناء فقط ثم نعيدها. db و redis يبقيان لأن
|
||||||
|
# الخطوات التالية تحتاجهما.
|
||||||
echo "🚗 Rebuilding GraphHopper routing graph (may take 5-10 min)..."
|
echo "🚗 Rebuilding GraphHopper routing graph (may take 5-10 min)..."
|
||||||
|
echo "⏸️ إيقاف مؤقت للحاويات غير الضرورية لتحرير الذاكرة أثناء البناء..."
|
||||||
|
docker compose stop api web dashboard martin || true
|
||||||
|
# نُعيدها مهما كانت نتيجة البناء — بما في ذلك الخروج المبكر عند الفشل
|
||||||
|
trap 'echo "▶️ إعادة تشغيل الحاويات المؤقتة..."; docker compose up -d api web dashboard martin || true' EXIT
|
||||||
|
|
||||||
docker compose stop routing
|
docker compose stop routing
|
||||||
rm -rf "${DATA_DIR}/graph-cache" "${DATA_DIR}/default-gh"
|
rm -rf "${DATA_DIR}/graph-cache" "${DATA_DIR}/default-gh"
|
||||||
docker compose up -d routing
|
docker compose up -d routing
|
||||||
echo "✅ GraphHopper restarting from ${MASTER_FILE}."
|
echo "✅ GraphHopper restarting from ${MASTER_FILE}."
|
||||||
|
|
||||||
|
# ── Step 6b: بوابة تحقق — اتجاهات السير ──────────────────────────────────
|
||||||
|
# انحدار 26/07/2026: سقوط قاعدة "!car_access → 0" جعل المحرك يتجاهل كل oneway
|
||||||
|
# وكل دوار لأيام دون أن يلاحظ أحد. لا نُنهي التحديث دون إثبات أنها تعمل.
|
||||||
|
# التفاصيل: docs/ROUTING_ONEWAY_AR.md
|
||||||
|
echo "⏳ انتظار جهوزية المحرك قبل فحص الاتجاهات..."
|
||||||
|
GH_READY=0
|
||||||
|
for _ in $(seq 1 90); do
|
||||||
|
if curl -fsS http://localhost:8989/health >/dev/null 2>&1; then GH_READY=1; break; fi
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$GH_READY" = "0" ]; then
|
||||||
|
echo "❌ المحرك لم يجهز خلال 15 دقيقة — راجع: docker compose logs -f routing"
|
||||||
|
echo " (السبب المتكرر: exited with code 137 أي نفاد ذاكرة أثناء البناء)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! APP_DIR="${APP_DIR}" bash "${APP_DIR}/infrastructure/scripts/verify-oneway.sh"; then
|
||||||
|
echo ""
|
||||||
|
echo "❌❌ المحرك يتجاهل اتجاهات السير — التوجيه غير صالح للاستخدام."
|
||||||
|
echo " اقرأ: docs/ROUTING_ONEWAY_AR.md"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Step 7: Cache flush ───────────────────────────────────────────────────
|
# ── Step 7: Cache flush ───────────────────────────────────────────────────
|
||||||
echo "🧹 Flushing Redis traffic cache..."
|
echo "🧹 Flushing Redis traffic cache..."
|
||||||
docker compose exec -T redis redis-cli flushall
|
docker compose exec -T redis redis-cli flushall
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ 10-day update complete — Jordan + Syria + Egypt + approved delta applied."
|
echo "✅ 10-day update complete — Jordan + Syria + Egypt + Iraq + approved delta applied."
|
||||||
echo " GraphHopper is rebuilding in the background. Allow 5-10 min for routing to be ready."
|
echo " GraphHopper is rebuilding in the background. Allow 5-10 min for routing to be ready."
|
||||||
|
|||||||
Executable
+82
@@ -0,0 +1,82 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# verify-oneway.sh — يتحقق أن محرك التوجيه يحترم اتجاهات السير
|
||||||
|
#
|
||||||
|
# يختار طرقاً موسومة oneway=yes من البيانات نفسها، ويطلب مساراً على كل منها
|
||||||
|
# في الاتجاهين. على طريق أحادي سليم يجب أن تختلف المسافتان (العودة تحتاج
|
||||||
|
# التفافاً). التطابق التام = المحرك يتجاهل oneway.
|
||||||
|
#
|
||||||
|
# السبب المعتاد للفشل: سقوط قاعدة "!car_access → 0" من custom_model.
|
||||||
|
# التفاصيل الكاملة: docs/ROUTING_ONEWAY_AR.md
|
||||||
|
#
|
||||||
|
# bash infrastructure/scripts/verify-oneway.sh
|
||||||
|
#
|
||||||
|
# متغيّرات: GH_URL (افتراضي http://localhost:8989)، BBOX، SAMPLES
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
APP_DIR="${APP_DIR:-/home/hamzadoctor/app}"
|
||||||
|
GH_URL="${GH_URL:-http://localhost:8989}"
|
||||||
|
PBF="${PBF:-${APP_DIR}/infrastructure/osm-data/master_map.osm.pbf}"
|
||||||
|
BBOX="${BBOX:-36.055,32.100,36.072,32.116}" # lon1,lat1,lon2,lat2
|
||||||
|
SAMPLES="${SAMPLES:-8}"
|
||||||
|
|
||||||
|
command -v osmium >/dev/null || { echo "❌ osmium غير مثبّت: apt-get install -y osmium-tool"; exit 2; }
|
||||||
|
[ -f "$PBF" ] || { echo "❌ الملف غير موجود: $PBF"; exit 2; }
|
||||||
|
curl -fsS "$GH_URL/health" >/dev/null || { echo "❌ المحرك لا يستجيب على $GH_URL"; exit 2; }
|
||||||
|
|
||||||
|
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
osmium extract -b "$BBOX" "$PBF" -o "$TMP/area.pbf" --overwrite >/dev/null 2>&1
|
||||||
|
osmium tags-filter "$TMP/area.pbf" w/oneway=yes -o "$TMP/ow.pbf" --overwrite >/dev/null 2>&1
|
||||||
|
osmium export -f geojson "$TMP/ow.pbf" -o "$TMP/ow.geojson" --overwrite >/dev/null 2>&1
|
||||||
|
|
||||||
|
GH_URL="$GH_URL" SAMPLES="$SAMPLES" python3 - "$TMP/ow.geojson" <<'PY'
|
||||||
|
import json, os, sys, urllib.request
|
||||||
|
|
||||||
|
gh = os.environ["GH_URL"]
|
||||||
|
want = int(os.environ["SAMPLES"])
|
||||||
|
|
||||||
|
def route(a, b):
|
||||||
|
u = f"{gh}/route?point={a}&point={b}&profile=car"
|
||||||
|
try:
|
||||||
|
return round(json.load(urllib.request.urlopen(u, timeout=20))["paths"][0]["distance"])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
feats = json.load(open(sys.argv[1]))["features"]
|
||||||
|
print("═" * 60)
|
||||||
|
print(" فحص احترام اتجاهات السير — " + gh)
|
||||||
|
print("═" * 60)
|
||||||
|
|
||||||
|
tested = identical = 0
|
||||||
|
for f in feats:
|
||||||
|
if tested >= want:
|
||||||
|
break
|
||||||
|
g = f["geometry"]
|
||||||
|
if g["type"] != "LineString" or len(g["coordinates"]) < 2:
|
||||||
|
continue
|
||||||
|
c = g["coordinates"]
|
||||||
|
a = f"{c[0][1]:.7f},{c[0][0]:.7f}"
|
||||||
|
b = f"{c[-1][1]:.7f},{c[-1][0]:.7f}"
|
||||||
|
fwd, rev = route(a, b), route(b, a)
|
||||||
|
if fwd is None or rev is None or fwd == 0:
|
||||||
|
continue
|
||||||
|
tested += 1
|
||||||
|
name = (f["properties"].get("name") or "بلا اسم")[:24]
|
||||||
|
# المعيار هو الاختلاف لا نسبته: الالتفاف قد يكون صغيراً نسبياً على مقطع طويل
|
||||||
|
ok = fwd != rev
|
||||||
|
identical += (not ok)
|
||||||
|
print(f" {name:24} ذهاب:{fwd:6d}م عودة:{rev:6d}م {'✅' if ok else '❌ متطابق'}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if tested == 0:
|
||||||
|
print("⚠️ لم يُعثر على طرق أحادية صالحة للاختبار — راجع BBOX أو البيانات.")
|
||||||
|
sys.exit(2)
|
||||||
|
if identical:
|
||||||
|
print(f"❌ فشل: {identical} من {tested} طريقاً أحادياً يُخترق — المحرك يتجاهل oneway.")
|
||||||
|
print(" الأرجح: سقوط قاعدة '!car_access → 0' من custom_model في config.yml")
|
||||||
|
print(" اقرأ: docs/ROUTING_ONEWAY_AR.md")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"✅ نجح: {tested} طريقاً أحادياً، كلها محترمة الاتجاه.")
|
||||||
|
PY
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "intaleq_maps",
|
"name": "intaleq_maps",
|
||||||
"version": "2.2.1",
|
"version": "2.3.0",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"flutter",
|
"flutter",
|
||||||
"http",
|
"http",
|
||||||
@@ -223,6 +223,17 @@
|
|||||||
"meta"
|
"meta"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "leak_tracker",
|
||||||
|
"version": "11.0.2",
|
||||||
|
"dependencies": [
|
||||||
|
"clock",
|
||||||
|
"collection",
|
||||||
|
"meta",
|
||||||
|
"path",
|
||||||
|
"vm_service"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "term_glyph",
|
"name": "term_glyph",
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
@@ -275,6 +286,11 @@
|
|||||||
"flutter"
|
"flutter"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "vm_service",
|
||||||
|
"version": "15.0.0",
|
||||||
|
"dependencies": []
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "xml",
|
"name": "xml",
|
||||||
"version": "6.6.1",
|
"version": "6.6.1",
|
||||||
@@ -313,22 +329,6 @@
|
|||||||
"name": "ffi",
|
"name": "ffi",
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"dependencies": []
|
"dependencies": []
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "leak_tracker",
|
|
||||||
"version": "11.0.2",
|
|
||||||
"dependencies": [
|
|
||||||
"clock",
|
|
||||||
"collection",
|
|
||||||
"meta",
|
|
||||||
"path",
|
|
||||||
"vm_service"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "vm_service",
|
|
||||||
"version": "15.0.0",
|
|
||||||
"dependencies": []
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"configVersion": 1
|
"configVersion": 1
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.dart_tool/
|
||||||
|
example/.dart_tool/
|
||||||
|
example/pubspec.lock
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
example/.flutter-plugins-dependencies
|
||||||
@@ -1,3 +1,19 @@
|
|||||||
|
## 2.3.0
|
||||||
|
|
||||||
|
* Refreshed bundled `assets/style.json` (offline fallback) to match the live server style:
|
||||||
|
slate-on-sand road palette, per-class zoom thresholds (fixes low-zoom street clutter),
|
||||||
|
building layer ordering fix (labels/icons no longer hidden under building fills),
|
||||||
|
bridge rendering, seasonal/intermittent waterway styling, expanded POI categories
|
||||||
|
(shop, bank, fuel, hotel, police), and OSM-Carto-style landuse tinting
|
||||||
|
(retail/farmland/forest/grass/power).
|
||||||
|
* Removed the broken `demotiles.maplibre.org` sprite reference from the bundled style —
|
||||||
|
apps must now supply POI icons via `onStyleLoaded` + `styleimagemissing` (see
|
||||||
|
`apps/web/src/utils/mapIcons.ts` in the monorepo for the reference implementation).
|
||||||
|
Without this, icon-based layers (POIs, direction arrows) render with empty icons.
|
||||||
|
* Note: this only updates the **local/offline** style asset. The primary runtime path
|
||||||
|
(`IntaleqStyles.light/obsidian`) already served the corrected style live via
|
||||||
|
`/api/maps/style.json` as of 2026-07-31 — no server change needed for those.
|
||||||
|
|
||||||
## 2.2.1
|
## 2.2.1
|
||||||
|
|
||||||
* Fixed Polyline and Marker comparison/equality checks.
|
* Fixed Polyline and Marker comparison/equality checks.
|
||||||
|
|||||||
+1115
-552
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ name: intaleq_maps
|
|||||||
description: >
|
description: >
|
||||||
Premium Flutter SDK for the Intaleq Map Platform (Jordan & Syria).
|
Premium Flutter SDK for the Intaleq Map Platform (Jordan & Syria).
|
||||||
A drop-in Google Maps Flutter replacement backed by MapLibre GL.
|
A drop-in Google Maps Flutter replacement backed by MapLibre GL.
|
||||||
version: 2.2.1
|
version: 2.3.0
|
||||||
homepage: https://intaleqapp.com
|
homepage: https://intaleqapp.com
|
||||||
repository: https://github.com/Hamza-Ayed/intaleq_maps
|
repository: https://github.com/Hamza-Ayed/intaleq_maps
|
||||||
issue_tracker: https://github.com/Hamza-Ayed/intaleq_maps/issues
|
issue_tracker: https://github.com/Hamza-Ayed/intaleq_maps/issues
|
||||||
|
|||||||
+1163
-1284
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user