2026-04-14-1
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { NeighborhoodPoint } from './entities/neighborhood-point.entity';
|
||||
import { NeighborhoodPolygon } from './entities/neighborhood-polygon.entity';
|
||||
import { AdminBoundary } from './entities/admin-boundary.entity';
|
||||
import { PlaceJordan } from './entities/place-jordan.entity';
|
||||
import { PlaceSyria } from './entities/place-syria.entity';
|
||||
import axios from 'axios';
|
||||
|
||||
@Injectable()
|
||||
export class AdministrativeLinkingService {
|
||||
private readonly logger = new Logger(AdministrativeLinkingService.name);
|
||||
|
||||
constructor(
|
||||
private dataSource: DataSource,
|
||||
@InjectRepository(NeighborhoodPoint)
|
||||
private readonly neighborhoodPointRepo: Repository<NeighborhoodPoint>,
|
||||
@InjectRepository(NeighborhoodPolygon)
|
||||
private readonly neighborhoodPolygonRepo: Repository<NeighborhoodPolygon>,
|
||||
@InjectRepository(AdminBoundary)
|
||||
private readonly adminBoundaryRepo: Repository<AdminBoundary>,
|
||||
) {}
|
||||
|
||||
private readonly overpassMirrors = [
|
||||
'https://overpass-api.de/api/interpreter',
|
||||
'https://overpass.kumi.systems/api/interpreter',
|
||||
'https://z.overpass-api.de/api/interpreter'
|
||||
];
|
||||
|
||||
private async fetchWithRetry(data: string, retries = 3): Promise<any> {
|
||||
for (let i = 0; i <= retries; i++) {
|
||||
const url = this.overpassMirrors[i % this.overpassMirrors.length];
|
||||
try {
|
||||
const response = await axios.post(url, data, {
|
||||
timeout: 45000,
|
||||
headers: {
|
||||
'User-Agent': 'IntaleqMapBot/1.0',
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Mirror ${url} failed: ${error.message}. Trying next mirror...`);
|
||||
if (i === retries) throw error;
|
||||
await new Promise(res => setTimeout(res, 2000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1: Sync Neighborhood Points from OSM for a given BBox
|
||||
*/
|
||||
async syncOsmNeighborhoodPoints(bbox: string = '31.86,35.94,32.22,36.25') {
|
||||
this.logger.log(`Syncing neighborhood points for bbox: ${bbox}`);
|
||||
const query = `[out:json][timeout:900];node["place"~"neighbourhood|suburb|town"](${bbox});out;`;
|
||||
|
||||
try {
|
||||
const data = await this.fetchWithRetry(`data=${encodeURIComponent(query)}`);
|
||||
const elements = data.elements;
|
||||
this.logger.log(`Found ${elements.length} points in OSM.`);
|
||||
|
||||
for (const e of elements) {
|
||||
const nameAr = e.tags['name:ar'] || e.tags['name'];
|
||||
const nameEn = e.tags['name:en'] || e.tags['name'];
|
||||
const geometry = { type: 'Point', coordinates: [e.lon, e.lat] };
|
||||
|
||||
await this.dataSource.query(`
|
||||
INSERT INTO neighborhood_points (osm_id, name_ar, name_en, place_type, geometry)
|
||||
VALUES ($1, $2, $3, $4, ST_SetSRID(ST_GeomFromGeoJSON($5), 4326))
|
||||
ON CONFLICT (osm_id) DO UPDATE SET
|
||||
name_ar = EXCLUDED.name_ar,
|
||||
name_en = EXCLUDED.name_en,
|
||||
place_type = EXCLUDED.place_type,
|
||||
geometry = EXCLUDED.geometry;
|
||||
`, [e.id, nameAr, nameEn, e.tags['place'], JSON.stringify(geometry)]);
|
||||
}
|
||||
|
||||
// Link points to parent districts using containment or nearest neighbor
|
||||
await this.dataSource.query(`
|
||||
UPDATE neighborhood_points np
|
||||
SET district_id = COALESCE(
|
||||
(
|
||||
SELECT id FROM admin_boundaries ab
|
||||
WHERE ab.admin_level IN (6, 8)
|
||||
AND ST_Contains(ab.geom::geometry, np.geometry::geometry)
|
||||
ORDER BY ab.admin_level DESC
|
||||
LIMIT 1
|
||||
),
|
||||
(
|
||||
SELECT id FROM admin_boundaries ab
|
||||
WHERE ab.admin_level IN (6, 8)
|
||||
ORDER BY ab.geom::geometry <-> np.geometry::geometry
|
||||
LIMIT 1
|
||||
)
|
||||
)
|
||||
WHERE district_id IS NULL;
|
||||
`);
|
||||
|
||||
// Diagnostics
|
||||
const totalPoints = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_points`);
|
||||
const linkedPoints = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_points WHERE district_id IS NOT NULL`);
|
||||
const unlinkedPoints = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_points WHERE district_id IS NULL`);
|
||||
const adminCount = await this.dataSource.query(`SELECT admin_level, count(*) as cnt FROM admin_boundaries GROUP BY admin_level ORDER BY admin_level`);
|
||||
|
||||
const diagnostics = {
|
||||
osm_fetched: elements.length,
|
||||
total_points_in_db: parseInt(totalPoints[0].cnt),
|
||||
linked_to_district: parseInt(linkedPoints[0].cnt),
|
||||
unlinked: parseInt(unlinkedPoints[0].cnt),
|
||||
admin_boundaries: adminCount.map(r => ({ level: r.admin_level, count: parseInt(r.cnt) })),
|
||||
};
|
||||
this.logger.log(`Step 1 diagnostics: ${JSON.stringify(diagnostics)}`);
|
||||
return diagnostics;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to sync OSM points: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: Generate Voronoi Polygons for Neighborhoods
|
||||
*/
|
||||
async generateVoronoiNeighborhoods() {
|
||||
this.logger.log('Generating Voronoi polygons for neighborhoods...');
|
||||
|
||||
// Pre-flight diagnostics
|
||||
const prePoints = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_points WHERE district_id IS NOT NULL`);
|
||||
const preDistricts = await this.dataSource.query(`SELECT district_id, count(*) as cnt FROM neighborhood_points WHERE district_id IS NOT NULL GROUP BY district_id`);
|
||||
this.logger.log(`Pre-flight: ${prePoints[0].cnt} points across ${preDistricts.length} districts`);
|
||||
|
||||
// Log a sample to verify geometry exists
|
||||
const samplePoints = await this.dataSource.query(`SELECT osm_id, name_ar, district_id, ST_AsText(geometry) as geom_text FROM neighborhood_points WHERE district_id IS NOT NULL LIMIT 3`);
|
||||
this.logger.log(`Sample points: ${JSON.stringify(samplePoints)}`);
|
||||
|
||||
const sampleDistricts = await this.dataSource.query(`SELECT id, name_ar, admin_level, ST_AsText(ST_Centroid(geom)) as centroid FROM admin_boundaries WHERE admin_level IN (6, 8) LIMIT 3`);
|
||||
this.logger.log(`Sample districts: ${JSON.stringify(sampleDistricts)}`);
|
||||
|
||||
// Delete old voronoi polygons
|
||||
await this.dataSource.query(`DELETE FROM neighborhood_polygons WHERE method = 'voronoi'`);
|
||||
this.logger.log('Deleted old voronoi polygons.');
|
||||
|
||||
// Generate Voronoi per district - do it one district at a time to avoid silent failures
|
||||
let totalInserted = 0;
|
||||
for (const row of preDistricts) {
|
||||
const districtId = row.district_id;
|
||||
const pointCount = parseInt(row.cnt);
|
||||
|
||||
try {
|
||||
if (pointCount >= 2) {
|
||||
// Normal Voronoi for 2+ points
|
||||
const result = await this.dataSource.query(`
|
||||
INSERT INTO neighborhood_polygons (osm_id, name_ar, name_en, place_type, parent_id, method, geometry)
|
||||
WITH voronoi_cells AS (
|
||||
SELECT (ST_Dump(ST_VoronoiPolygons(
|
||||
ST_Collect(np.geometry::geometry),
|
||||
0.00001,
|
||||
d.geom::geometry
|
||||
))).geom as cell
|
||||
FROM neighborhood_points np
|
||||
JOIN admin_boundaries d ON d.id = np.district_id
|
||||
WHERE np.district_id = $1
|
||||
GROUP BY d.geom
|
||||
),
|
||||
matched AS (
|
||||
SELECT
|
||||
np.osm_id, np.name_ar, np.name_en, np.place_type, np.district_id,
|
||||
ST_Multi(ST_Intersection(vc.cell::geometry, d.geom::geometry)) as geometry
|
||||
FROM voronoi_cells vc
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT np2.*
|
||||
FROM neighborhood_points np2
|
||||
WHERE np2.district_id = $1
|
||||
ORDER BY np2.geometry::geometry <-> vc.cell::geometry
|
||||
LIMIT 1
|
||||
) np
|
||||
JOIN admin_boundaries d ON d.id = $1
|
||||
)
|
||||
SELECT osm_id, name_ar, name_en, place_type, district_id, 'voronoi', geometry
|
||||
FROM matched
|
||||
WHERE ST_IsValid(geometry) AND NOT ST_IsEmpty(geometry)
|
||||
`, [districtId]);
|
||||
|
||||
totalInserted += (result?.length || result?.[1] || 0);
|
||||
this.logger.log(`District ${districtId}: ${pointCount} points -> ${result?.length || result?.[1] || '?'} polygons`);
|
||||
} else if (pointCount === 1) {
|
||||
// Single point: assign entire district boundary
|
||||
const result = await this.dataSource.query(`
|
||||
INSERT INTO neighborhood_polygons (osm_id, name_ar, name_en, place_type, parent_id, method, geometry)
|
||||
SELECT np.osm_id, np.name_ar, np.name_en, np.place_type, np.district_id, 'voronoi', ST_Multi(d.geom::geometry)
|
||||
FROM neighborhood_points np
|
||||
JOIN admin_boundaries d ON d.id = np.district_id
|
||||
WHERE np.district_id = $1
|
||||
`, [districtId]);
|
||||
|
||||
totalInserted += (result?.length || result?.[1] || 0);
|
||||
this.logger.log(`District ${districtId}: 1 point -> assigned district boundary`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Voronoi FAILED for district ${districtId} (${pointCount} points): ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Post-flight count
|
||||
const postCount = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_polygons`);
|
||||
const finalCount = parseInt(postCount[0].cnt);
|
||||
this.logger.log(`Step 2 complete: ${finalCount} total polygons in neighborhood_polygons`);
|
||||
|
||||
// Sample polygon names
|
||||
const samplePolygons = await this.dataSource.query(`SELECT id, osm_id, name_ar, parent_id, method FROM neighborhood_polygons LIMIT 5`);
|
||||
this.logger.log(`Sample polygons: ${JSON.stringify(samplePolygons)}`);
|
||||
|
||||
return {
|
||||
status: finalCount > 0 ? 'success' : 'WARNING_EMPTY',
|
||||
total_polygons: finalCount,
|
||||
districts_processed: preDistricts.length,
|
||||
sample: samplePolygons
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3: Link all Places (Jordan & Syria) to the full administrative hierarchy
|
||||
*/
|
||||
async linkPlaces(country: 'jordan' | 'syria') {
|
||||
const tableName = country === 'jordan' ? 'places_jordan' : 'places_syria';
|
||||
|
||||
// Pre-flight diagnostics
|
||||
const polyCount = await this.dataSource.query(`SELECT count(*) as cnt FROM neighborhood_polygons`);
|
||||
const placeCount = await this.dataSource.query(`SELECT count(*) as cnt FROM ${tableName} WHERE location IS NOT NULL`);
|
||||
this.logger.log(`linkPlaces pre-flight: ${polyCount[0].cnt} polygons, ${placeCount[0].cnt} places with location in ${tableName}`);
|
||||
|
||||
if (parseInt(polyCount[0].cnt) === 0) {
|
||||
this.logger.error('ABORT: neighborhood_polygons is EMPTY. Run generate-voronoi first!');
|
||||
return {
|
||||
status: 'FAILED',
|
||||
error: 'neighborhood_polygons table is empty. Run generate-voronoi first.',
|
||||
polygons: 0,
|
||||
places: parseInt(placeCount[0].cnt)
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.log(`Linking administrative hierarchy for ${tableName} using KNN...`);
|
||||
|
||||
// Update in smaller batches to avoid timeout and track progress
|
||||
const updateResult = await this.dataSource.query(`
|
||||
UPDATE ${tableName} p
|
||||
SET
|
||||
neighborhood_id = (
|
||||
SELECT np.id FROM neighborhood_polygons np
|
||||
ORDER BY p.location::geometry <-> np.geometry::geometry
|
||||
LIMIT 1
|
||||
),
|
||||
sub_district_id = (
|
||||
SELECT ab.id FROM admin_boundaries ab
|
||||
WHERE ab.admin_level = 8
|
||||
AND ST_Within(p.location::geometry, ab.geom::geometry)
|
||||
LIMIT 1
|
||||
),
|
||||
district_id = (
|
||||
SELECT ab.id FROM admin_boundaries ab
|
||||
WHERE ab.admin_level = 6
|
||||
AND ST_Within(p.location::geometry, ab.geom::geometry)
|
||||
LIMIT 1
|
||||
),
|
||||
governorate_id = (
|
||||
SELECT ab.id FROM admin_boundaries ab
|
||||
WHERE ab.admin_level = 4
|
||||
AND ST_Within(p.location::geometry, ab.geom::geometry)
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE p.location IS NOT NULL;
|
||||
`);
|
||||
|
||||
// Post-flight diagnostics
|
||||
const linkedNeighborhood = await this.dataSource.query(`SELECT count(*) as cnt FROM ${tableName} WHERE neighborhood_id IS NOT NULL`);
|
||||
const linkedDistrict = await this.dataSource.query(`SELECT count(*) as cnt FROM ${tableName} WHERE district_id IS NOT NULL`);
|
||||
const linkedGov = await this.dataSource.query(`SELECT count(*) as cnt FROM ${tableName} WHERE governorate_id IS NOT NULL`);
|
||||
const totalPlaces = await this.dataSource.query(`SELECT count(*) as cnt FROM ${tableName}`);
|
||||
|
||||
// Sample a specific place to verify
|
||||
const samplePlace = await this.dataSource.query(`
|
||||
SELECT p.id, p.name_ar, p.neighborhood_id, p.district_id, p.governorate_id,
|
||||
n.name_ar as neighborhood_name
|
||||
FROM ${tableName} p
|
||||
LEFT JOIN neighborhood_polygons n ON p.neighborhood_id = n.id
|
||||
WHERE p.name_ar LIKE '%معصوم%'
|
||||
LIMIT 3
|
||||
`);
|
||||
|
||||
const diagnostics = {
|
||||
status: 'completed',
|
||||
country,
|
||||
total_places: parseInt(totalPlaces[0].cnt),
|
||||
linked_neighborhood: parseInt(linkedNeighborhood[0].cnt),
|
||||
linked_district: parseInt(linkedDistrict[0].cnt),
|
||||
linked_governorate: parseInt(linkedGov[0].cnt),
|
||||
sample_masoum: samplePlace,
|
||||
rows_affected: updateResult?.[1] || 'unknown'
|
||||
};
|
||||
|
||||
this.logger.log(`Step 3 diagnostics: ${JSON.stringify(diagnostics)}`);
|
||||
return diagnostics;
|
||||
}
|
||||
}
|
||||
@@ -48,17 +48,17 @@ export abstract class BasePlace {
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
@Index()
|
||||
admin_level4_id: number;
|
||||
governorate_id: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
@Index()
|
||||
admin_level6_id: number;
|
||||
district_id: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
@Index()
|
||||
admin_level8_id: number;
|
||||
sub_district_id: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
@Index()
|
||||
admin_level10_id: number;
|
||||
neighborhood_id: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
|
||||
|
||||
@Entity('neighborhood_points')
|
||||
export class NeighborhoodPoint {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'bigint', unique: true, nullable: true })
|
||||
@Index()
|
||||
osm_id: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Index()
|
||||
name_ar: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
name_en: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
place_type: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
@Index()
|
||||
district_id: number;
|
||||
|
||||
@Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326 })
|
||||
@Index({ spatial: true })
|
||||
geometry: any;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';
|
||||
|
||||
@Entity('neighborhood_polygons')
|
||||
export class NeighborhoodPolygon {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'bigint', nullable: true })
|
||||
@Index()
|
||||
osm_id: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Index()
|
||||
name_ar: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
name_en: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
place_type: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
@Index()
|
||||
parent_id: number; // Links to admin_boundaries (District/Sub-district)
|
||||
|
||||
@Column({ default: 'voronoi' })
|
||||
method: string;
|
||||
|
||||
@Column({ type: 'float', default: 0.7 })
|
||||
confidence: number;
|
||||
|
||||
@Column({ type: 'geometry', spatialFeatureType: 'MultiPolygon', srid: 4326 })
|
||||
@Index({ spatial: true })
|
||||
geometry: any;
|
||||
}
|
||||
@@ -19,6 +19,28 @@ export class GeocodingInitService implements OnModuleInit {
|
||||
await this.repo.query('CREATE EXTENSION IF NOT EXISTS postgis;');
|
||||
await this.repo.query('CREATE EXTENSION IF NOT EXISTS pg_trgm;');
|
||||
|
||||
// Migration: Rename old admin_level columns to descriptive names if they exist
|
||||
const tables = ['places_jordan', 'places_syria', 'places_egypt'];
|
||||
const columnMapping = [
|
||||
{ old: 'admin_level4_id', new: 'governorate_id' },
|
||||
{ old: 'admin_level6_id', new: 'district_id' },
|
||||
{ old: 'admin_level8_id', new: 'sub_district_id' },
|
||||
{ old: 'admin_level10_id', new: 'neighborhood_id' }
|
||||
];
|
||||
|
||||
for (const table of tables) {
|
||||
for (const mapping of columnMapping) {
|
||||
await this.repo.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = '${table}' AND column_name = '${mapping.old}') THEN
|
||||
ALTER TABLE ${table} RENAME COLUMN ${mapping.old} TO ${mapping.new};
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup: Create a trigger to automatically update 'location' when lat/lng changes
|
||||
await this.repo.query(`
|
||||
CREATE OR REPLACE FUNCTION sync_place_location() RETURNS trigger AS $$
|
||||
@@ -26,10 +48,13 @@ export class GeocodingInitService implements OnModuleInit {
|
||||
IF NEW.latitude IS NOT NULL AND NEW.longitude IS NOT NULL THEN
|
||||
NEW.location := ST_SetSRID(ST_MakePoint(CAST(NEW.longitude AS FLOAT), CAST(NEW.latitude AS FLOAT)), 4326);
|
||||
|
||||
NEW.admin_level4_id := (SELECT id FROM admin_boundaries WHERE admin_level = 4 AND ST_Contains(geom, NEW.location) LIMIT 1);
|
||||
NEW.admin_level6_id := (SELECT id FROM admin_boundaries WHERE admin_level = 6 AND ST_Contains(geom, NEW.location) LIMIT 1);
|
||||
NEW.admin_level8_id := (SELECT id FROM admin_boundaries WHERE admin_level = 8 AND ST_Contains(geom, NEW.location) LIMIT 1);
|
||||
NEW.admin_level10_id := (SELECT id FROM admin_boundaries WHERE admin_level = 10 AND ST_Contains(geom, NEW.location) LIMIT 1);
|
||||
-- Keep standard admin boundary logic for higher levels
|
||||
NEW.governorate_id := (SELECT id FROM admin_boundaries WHERE admin_level = 4 AND ST_Contains(geom, NEW.location) LIMIT 1);
|
||||
NEW.district_id := (SELECT id FROM admin_boundaries WHERE admin_level = 6 AND ST_Contains(geom, NEW.location) LIMIT 1);
|
||||
NEW.sub_district_id := (SELECT id FROM admin_boundaries WHERE admin_level = 8 AND ST_Contains(geom, NEW.location) LIMIT 1);
|
||||
|
||||
-- ROOT CAUSE FIX: Use the new Voronoi polygons for neighborhoods!
|
||||
NEW.neighborhood_id := (SELECT id FROM neighborhood_polygons ORDER BY NEW.location::geometry <-> geometry::geometry LIMIT 1);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Controller, Get, Post, Delete, Body, Query, UseGuards, HttpException, H
|
||||
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import { GeocodingService } from './geocoding.service';
|
||||
import { AdminBoundariesService } from './admin-boundaries.service';
|
||||
import { JordanResearchService } from './jordan-research.service';
|
||||
import { AdministrativeLinkingService } from './administrative-linking.service';
|
||||
import { ApiKeyGuard } from '../common/guards/api-key.guard';
|
||||
|
||||
@ApiTags('geocoding')
|
||||
@@ -10,6 +12,8 @@ export class GeocodingController {
|
||||
constructor(
|
||||
private readonly geocodingService: GeocodingService,
|
||||
private readonly adminBoundariesService: AdminBoundariesService,
|
||||
private readonly jordanResearchService: JordanResearchService,
|
||||
private readonly adminLinkingService: AdministrativeLinkingService,
|
||||
) {}
|
||||
|
||||
@Get('search')
|
||||
@@ -120,4 +124,33 @@ export class GeocodingController {
|
||||
) {
|
||||
return this.adminBoundariesService.importFromFile(country, filePath);
|
||||
}
|
||||
|
||||
@Get('research/zarqa')
|
||||
@ApiOperation({ summary: 'Generate a research report for Zarqa, Jordan (Sample Data)' })
|
||||
async zarqaResearch() {
|
||||
return this.jordanResearchService.generateZarqaReport();
|
||||
}
|
||||
|
||||
@Post('admin/sync-neighborhoods')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@ApiOperation({ summary: 'Sync neighborhood points from OSM for a bbox' })
|
||||
@ApiQuery({ name: 'bbox', required: false })
|
||||
async syncNeighborhoods(@Query('bbox') bbox?: string) {
|
||||
return this.adminLinkingService.syncOsmNeighborhoodPoints(bbox);
|
||||
}
|
||||
|
||||
@Post('admin/generate-voronoi')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@ApiOperation({ summary: 'Generate Voronoi polygons for neighborhoods' })
|
||||
async generateVoronoi() {
|
||||
return this.adminLinkingService.generateVoronoiNeighborhoods();
|
||||
}
|
||||
|
||||
@Post('admin/link-places')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@ApiOperation({ summary: 'Link places to administrative hierarchy' })
|
||||
@ApiQuery({ name: 'country', required: true, enum: ['jordan', 'syria'] })
|
||||
async linkPlaces(@Query('country') country: 'jordan' | 'syria') {
|
||||
return this.adminLinkingService.linkPlaces(country);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ import { OsmArea } from './entities/osm-area.entity';
|
||||
import { OsmPointWithArea } from './entities/osm-point-with-area.entity';
|
||||
import { AdminBoundary } from './entities/admin-boundary.entity';
|
||||
import { AdminBoundariesService } from './admin-boundaries.service';
|
||||
import { JordanResearchService } from './jordan-research.service';
|
||||
import { AdministrativeLinkingService } from './administrative-linking.service';
|
||||
import { NeighborhoodPoint } from './entities/neighborhood-point.entity';
|
||||
import { NeighborhoodPolygon } from './entities/neighborhood-polygon.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
@@ -18,10 +23,18 @@ import { AdminBoundariesService } from './admin-boundaries.service';
|
||||
PlaceEgypt,
|
||||
OsmArea,
|
||||
OsmPointWithArea,
|
||||
AdminBoundary
|
||||
AdminBoundary,
|
||||
NeighborhoodPoint,
|
||||
NeighborhoodPolygon
|
||||
]),
|
||||
],
|
||||
controllers: [GeocodingController],
|
||||
providers: [GeocodingService, GeocodingInitService, AdminBoundariesService],
|
||||
providers: [
|
||||
GeocodingService,
|
||||
GeocodingInitService,
|
||||
AdminBoundariesService,
|
||||
JordanResearchService,
|
||||
AdministrativeLinkingService
|
||||
],
|
||||
})
|
||||
export class GeocodingModule {}
|
||||
|
||||
@@ -69,6 +69,7 @@ export class GeocodingService {
|
||||
const userQuery = `
|
||||
SELECT
|
||||
p.id, p.name, p.name_ar, p.name_en, p.category,
|
||||
p.neighborhood_id as db_neighborhood_id,
|
||||
p.neighbourhood as original_neighbourhood,
|
||||
n.name_ar as neighbourhood,
|
||||
d.name_ar as district,
|
||||
@@ -77,9 +78,9 @@ export class GeocodingService {
|
||||
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(p.location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
|
||||
similarity(COALESCE(p.name_ar, ''), $1) + similarity(COALESCE(p.name, ''), $1) + similarity(COALESCE(p.neighbourhood, ''), $1) as relevance
|
||||
FROM ${tableName} p
|
||||
LEFT JOIN admin_boundaries n ON p.admin_level10_id = n.id
|
||||
LEFT JOIN admin_boundaries d ON p.admin_level8_id = d.id
|
||||
LEFT JOIN admin_boundaries g ON p.admin_level4_id = g.id
|
||||
LEFT JOIN neighborhood_polygons n ON p.neighborhood_id = n.id
|
||||
LEFT JOIN admin_boundaries d ON p.sub_district_id = d.id
|
||||
LEFT JOIN admin_boundaries g ON p.governorate_id = g.id
|
||||
WHERE (p.name_ar % $1 OR p.name % $1 OR p.neighbourhood % $1 OR p.name_ar ILIKE $4 OR p.name ILIKE $4 OR p.neighbourhood ILIKE $4)
|
||||
${hasLocation ? `AND (p.location && ST_Expand(ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326), $5::float) OR ST_DistanceSphere(p.location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $6::float)` : ''}
|
||||
ORDER BY relevance DESC, distance ASC LIMIT 15
|
||||
@@ -107,55 +108,27 @@ export class GeocodingService {
|
||||
const osmResults = await this.osmPointsRepository.query(osmQuery, [cleanQuery, lat || null, lon || null, ILikeQuery, radiusInDegrees, radius]);
|
||||
allResults.push(...osmResults);
|
||||
|
||||
// --- Consolidation: Overture Maps Integration (Now in primary DB) ---
|
||||
const overtureQuery = `
|
||||
(SELECT id::text,
|
||||
COALESCE(names->>'primary', names->>'common', 'Building') as name,
|
||||
COALESCE(names->>'primary', names->>'common', '') as name_ar,
|
||||
NULL as name_en,
|
||||
'building' as category,
|
||||
ST_Y(ST_Centroid(location)) as latitude,
|
||||
ST_X(ST_Centroid(location)) as longitude,
|
||||
'' as address,
|
||||
'overture_buildings' as source,
|
||||
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
|
||||
0.6 as relevance
|
||||
FROM overture_building
|
||||
WHERE (names->>'primary' ILIKE $4 OR names->>'common' ILIKE $4)
|
||||
LIMIT 15)
|
||||
UNION ALL
|
||||
(SELECT id::text,
|
||||
COALESCE(names->>'primary', names->>'common', 'Street') as name,
|
||||
COALESCE(names->>'primary', names->>'common', '') as name_ar,
|
||||
NULL as name_en,
|
||||
'transportation' as category,
|
||||
ST_Y(ST_Centroid(location)) as latitude,
|
||||
ST_X(ST_Centroid(location)) as longitude,
|
||||
'' as address,
|
||||
'overture_streets' as source,
|
||||
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
|
||||
0.6 as relevance
|
||||
FROM overture_segment
|
||||
WHERE (names->>'primary' ILIKE $4 OR names->>'common' ILIKE $4)
|
||||
LIMIT 15)
|
||||
`;
|
||||
try {
|
||||
const overtureResults = await this.osmPointsRepository.query(overtureQuery, [cleanQuery, lat || null, lon || null, ILikeQuery]);
|
||||
allResults.push(...overtureResults);
|
||||
} catch (err) {
|
||||
this.logger.warn('Overture tables search failed, logging error.', err);
|
||||
}
|
||||
// Note: We removed the raw query to 'overture_building' because raw overture tables
|
||||
// do not have administrative linking, causing empty full_addresses, and
|
||||
// scanning them with ILIKE without trigram indices causes a 2-second latency spike.
|
||||
// Overture data is already properly ingested via the Scraper into places_jordan.
|
||||
|
||||
const sortedResults = allResults
|
||||
.sort((a, b) => hasLocation ? ((a.distance - b.distance) || (b.relevance - a.relevance)) : ((b.relevance - a.relevance) || (a.distance - b.distance)))
|
||||
.slice(0, 25)
|
||||
.map(r => ({
|
||||
...r,
|
||||
latitude: parseFloat(r.latitude),
|
||||
longitude: parseFloat(r.longitude),
|
||||
distance_km: r.distance ? (Number(r.distance) / 1000).toFixed(2) : null,
|
||||
location: { lat: parseFloat(r.latitude), lng: parseFloat(r.longitude) },
|
||||
}));
|
||||
.map(r => {
|
||||
// Build full administrative address from admin_boundaries
|
||||
const addressParts = [r.neighbourhood, r.district, r.governorate].filter(Boolean);
|
||||
const full_address = addressParts.length > 0 ? addressParts.join('، ') : (r.address || '');
|
||||
return {
|
||||
...r,
|
||||
latitude: parseFloat(r.latitude),
|
||||
longitude: parseFloat(r.longitude),
|
||||
distance_km: r.distance ? (Number(r.distance) / 1000).toFixed(2) : null,
|
||||
location: { lat: parseFloat(r.latitude), lng: parseFloat(r.longitude) },
|
||||
full_address,
|
||||
};
|
||||
});
|
||||
|
||||
return { results: sortedResults };
|
||||
} catch (e) {
|
||||
@@ -177,9 +150,9 @@ export class GeocodingService {
|
||||
p.latitude, p.longitude, p.address, 'user_place' as source,
|
||||
ST_DistanceSphere(p.location::geometry, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
|
||||
FROM ${tableName} p
|
||||
LEFT JOIN admin_boundaries n ON p.admin_level10_id = n.id
|
||||
LEFT JOIN admin_boundaries d ON p.admin_level8_id = d.id
|
||||
LEFT JOIN admin_boundaries g ON p.admin_level4_id = g.id
|
||||
LEFT JOIN neighborhood_polygons n ON p.neighborhood_id = n.id
|
||||
LEFT JOIN admin_boundaries d ON p.sub_district_id = d.id
|
||||
LEFT JOIN admin_boundaries g ON p.governorate_id = g.id
|
||||
WHERE p.location IS NOT NULL
|
||||
ORDER BY p.location::geometry <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 3
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PlaceJordan } from './entities/place-jordan.entity';
|
||||
import axios from 'axios';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@Injectable()
|
||||
export class JordanResearchService {
|
||||
private readonly logger = new Logger(JordanResearchService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PlaceJordan)
|
||||
private readonly placeRepo: Repository<PlaceJordan>,
|
||||
) {}
|
||||
|
||||
private async fetchWithRetry(url: string, data?: string, method: 'get' | 'post' = 'get', retries = 2) {
|
||||
for (let i = 0; i <= retries; i++) {
|
||||
try {
|
||||
const config = {
|
||||
timeout: 20000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; IntaleqMapBot/1.0; +https://intaleq.xyz)',
|
||||
'Accept': 'application/json, application/sparql-results+json'
|
||||
}
|
||||
};
|
||||
const response = method === 'post'
|
||||
? await axios.post(url, data, config)
|
||||
: await axios.get(url, { ...config, params: data ? { query: data, format: 'json' } : {} });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (i === retries) {
|
||||
this.logger.error(`Failed to fetch from ${url} after ${retries} retries: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
await new Promise(res => setTimeout(res, 2000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Source 1: OSM (Overpass API) with Geometries
|
||||
*/
|
||||
async fetchOsmZarqa() {
|
||||
this.logger.log('Fetching Zarqa Geometries from OSM...');
|
||||
const query = `[out:json];(relation["boundary"="administrative"]["admin_level"~"6|8|10"](31.86,35.94,32.22,36.25);node["place"~"neighbourhood|suburb|town"](31.86,35.94,32.22,36.25););out geom;`;
|
||||
try {
|
||||
const data = await this.fetchWithRetry('https://overpass-api.de/api/interpreter', `data=${encodeURIComponent(query)}`, 'post');
|
||||
return data.elements.map(e => ({
|
||||
id: e.id,
|
||||
name_ar: e.tags['name:ar'] || e.tags['name'],
|
||||
type: e.tags['admin_level'] ? `admin_level_${e.tags['admin_level']}` : `place_${e.tags['place']}`,
|
||||
geometry: e.geometry ? e.geometry : (e.lat && e.lon ? { type: 'Point', coordinates: [e.lon, e.lat] } : null)
|
||||
}));
|
||||
} catch (e) { return { error: `OSM Fetch failed: ${e.message}` }; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Source 2: Overture (Local DB) - Corrected columns
|
||||
*/
|
||||
async fetchOvertureZarqa() {
|
||||
try {
|
||||
const tableCheck = await this.placeRepo.query("SELECT count(*) FROM information_schema.tables WHERE table_name = 'overture_segment'");
|
||||
if (parseInt(tableCheck[0].count) === 0) return { error: 'table overture_segment does not exist in this database' };
|
||||
|
||||
// Probe columns to avoid 'highway' error
|
||||
const columns = await this.placeRepo.query("SELECT column_name FROM information_schema.columns WHERE table_name = 'overture_segment'");
|
||||
const colList = columns.map(c => c.column_name);
|
||||
const roadClassCol = colList.includes('road_class') ? 'road_class' : (colList.includes('class') ? 'class' : 'NULL');
|
||||
|
||||
return await this.placeRepo.query(`
|
||||
SELECT DISTINCT COALESCE(names->>'primary', names->>'common') as name_ar, ${roadClassCol} as road_class,
|
||||
ST_AsGeoJSON(ST_Centroid(location)) as centroid
|
||||
FROM overture_segment
|
||||
WHERE (names->>'primary' IS NOT NULL OR names->>'common' IS NOT NULL)
|
||||
AND ST_Within(location, ST_MakeEnvelope(35.94, 31.86, 36.25, 32.22, 4326))
|
||||
LIMIT 10
|
||||
`);
|
||||
} catch (e) { return { error: `Overture query failed: ${e.message}` }; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Source 3: Wikidata (SPARQL) with User-Agent
|
||||
*/
|
||||
async fetchWikidataZarqa() {
|
||||
const sparql = `SELECT ?item ?itemLabel WHERE { ?item wdt:P131 wd:Q231710. SERVICE wikibase:label { bd:serviceParam wikibase:language "ar,en". } } LIMIT 20`;
|
||||
try {
|
||||
const data = await this.fetchWithRetry('https://query.wikidata.org/sparql', sparql, 'get');
|
||||
return data.results.bindings.map(b => ({ id: b.item.value.split('/').pop(), name_ar: b.itemLabel.value }));
|
||||
} catch (e) { return { error: `Wikidata Fetch failed: ${e.message}` }; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Source 4 & 5: Static Files (HDX & GADM)
|
||||
*/
|
||||
async checkStaticFiles() {
|
||||
// Check various common paths from overture_ingest script or data directory
|
||||
const paths = ['/data/infrastructure/osm-data/', './data/', './infrastructure/osm-data/'];
|
||||
const files = ['jor_adm2_geoboundaries.geojson', 'gadm41_JOR_2.json'];
|
||||
const results = {};
|
||||
|
||||
for (const file of files) {
|
||||
let found = false;
|
||||
for (const p of paths) {
|
||||
if (fs.existsSync(path.join(p, file))) {
|
||||
results[file] = `Found at ${p}`;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) results[file] = 'Not Found locally - Check if overture_ingest.sh was run for division_area';
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async generateZarqaReport() {
|
||||
const [osm, overture, wikidata, staticFiles] = await Promise.all([
|
||||
this.fetchOsmZarqa(),
|
||||
this.fetchOvertureZarqa(),
|
||||
this.fetchWikidataZarqa(),
|
||||
this.checkStaticFiles()
|
||||
]);
|
||||
|
||||
return {
|
||||
area: 'Zarqa Governorate, Jordan',
|
||||
report_timestamp: new Date().toISOString(),
|
||||
sources: {
|
||||
osm,
|
||||
overture,
|
||||
wikidata,
|
||||
static_files: staticFiles,
|
||||
hdx: "Requires GeoBoundaries GeoJSON for Level 2 (Districts)",
|
||||
gadm: "Requires GADM v4.1 for Level 1-2"
|
||||
},
|
||||
comparison_summary: "Multi-source research enabled with geometries. OSM provides precise neighborhood centroids and boundaries where available. Use the provided centroids to visualize Zarqa subunits."
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -294,6 +294,11 @@
|
||||
<input type="checkbox" id="toggle-pois" checked onchange="updateLayers()" style="accent-color: var(--primary); width: 16px; height: 16px;">
|
||||
<span>عرض المعالم / Show POIs</span>
|
||||
</label>
|
||||
|
||||
<label style="display: flex; align-items: center; gap: 10px; margin-top: 12px; cursor: pointer; font-size: 13px;">
|
||||
<input type="checkbox" id="toggle-arrows" checked onchange="updateLayers()" style="accent-color: var(--primary); width: 16px; height: 16px;">
|
||||
<span>اتجاهات الشوارع / Street Directions</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- DB NAMES INDICATOR -->
|
||||
@@ -672,9 +677,15 @@
|
||||
function updateLayers() {
|
||||
const show3D = document.getElementById('toggle-3d').checked;
|
||||
const showPOIs = document.getElementById('toggle-pois').checked;
|
||||
const showArrows = document.getElementById('toggle-arrows').checked;
|
||||
|
||||
if (map.getLayer('3d-buildings')) {
|
||||
map.setLayoutProperty('3d-buildings', 'visibility', show3D ? 'visible' : 'none');
|
||||
// Toggle 3D Overture buildings
|
||||
if (map.getLayer('building-3d')) {
|
||||
map.setLayoutProperty('building-3d', 'visibility', show3D ? 'visible' : 'none');
|
||||
}
|
||||
// Show flat footprints when 3D is off
|
||||
if (map.getLayer('overture-building-footprint')) {
|
||||
map.setLayoutProperty('overture-building-footprint', 'visibility', show3D ? 'none' : 'visible');
|
||||
}
|
||||
|
||||
if (map.getLayer('intaleq-db-label')) {
|
||||
@@ -685,6 +696,18 @@
|
||||
if (map.getLayer('intaleq_pois')) {
|
||||
map.setLayoutProperty('intaleq_pois', 'visibility', showPOIs ? 'visible' : 'none');
|
||||
}
|
||||
|
||||
// Toggle places label layers + Overture name layers
|
||||
['places-syria-labels', 'places-jordan-labels', 'places-egypt-labels', 'overture-building-names', 'overture-street-names'].forEach(id => {
|
||||
if (map.getLayer(id)) {
|
||||
map.setLayoutProperty(id, 'visibility', showPOIs ? 'visible' : 'none');
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle street direction arrows
|
||||
if (map.getLayer('road-direction-arrows')) {
|
||||
map.setLayoutProperty('road-direction-arrows', 'visibility', showArrows ? 'visible' : 'none');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -42,6 +42,16 @@
|
||||
"https://tiles.intaleqapp.com/places_egypt/{z}/{x}/{y}"
|
||||
],
|
||||
"maxzoom": 14
|
||||
},
|
||||
"overture_buildings": {
|
||||
"type": "vector",
|
||||
"tiles": ["https://tiles.intaleqapp.com/overture_building/{z}/{x}/{y}"],
|
||||
"maxzoom": 14
|
||||
},
|
||||
"overture_segments": {
|
||||
"type": "vector",
|
||||
"tiles": ["https://tiles.intaleqapp.com/overture_segment/{z}/{x}/{y}"],
|
||||
"maxzoom": 14
|
||||
}
|
||||
},
|
||||
"layers": [
|
||||
@@ -909,78 +919,18 @@
|
||||
{
|
||||
"id": "building-3d",
|
||||
"type": "fill-extrusion",
|
||||
"source": "local-osm-polygons",
|
||||
"source-layer": "planet_osm_polygon",
|
||||
"source": "overture_buildings",
|
||||
"source-layer": "overture_building",
|
||||
"minzoom": 15,
|
||||
"filter": [
|
||||
"has",
|
||||
"building"
|
||||
],
|
||||
"paint": {
|
||||
"fill-extrusion-color": [
|
||||
"match",
|
||||
[
|
||||
"get",
|
||||
"building"
|
||||
],
|
||||
"commercial",
|
||||
"#E0D8CC",
|
||||
"retail",
|
||||
"#E8DDD0",
|
||||
"industrial",
|
||||
"#D8DDE4",
|
||||
"church",
|
||||
"#E4DCF0",
|
||||
"mosque",
|
||||
"#D4EAD8",
|
||||
"hospital",
|
||||
"#F0E0E0",
|
||||
"school",
|
||||
"#E8ECD4",
|
||||
"university",
|
||||
"#E4E8D4",
|
||||
"hotel",
|
||||
"#E0E4EE",
|
||||
"apartments",
|
||||
"#E8E4DC",
|
||||
"#E4DFDA"
|
||||
],
|
||||
"fill-extrusion-color": "#DDD8D0",
|
||||
"fill-extrusion-height": [
|
||||
"coalesce",
|
||||
[
|
||||
"to-number",
|
||||
[
|
||||
"get",
|
||||
"height"
|
||||
],
|
||||
0
|
||||
],
|
||||
[
|
||||
"*",
|
||||
[
|
||||
"to-number",
|
||||
[
|
||||
"get",
|
||||
"building:levels"
|
||||
],
|
||||
3
|
||||
],
|
||||
3.5
|
||||
],
|
||||
["to-number", ["get", "height"], 0],
|
||||
["*", ["coalesce", ["to-number", ["get", "num_floors"], null], 3], 3.5],
|
||||
12
|
||||
],
|
||||
"fill-extrusion-base": [
|
||||
"coalesce",
|
||||
[
|
||||
"to-number",
|
||||
[
|
||||
"get",
|
||||
"min_height"
|
||||
],
|
||||
0
|
||||
],
|
||||
0
|
||||
],
|
||||
"fill-extrusion-base": 0,
|
||||
"fill-extrusion-opacity": 0.85,
|
||||
"fill-extrusion-vertical-gradient": true
|
||||
}
|
||||
|
||||
+86
-164
@@ -919,6 +919,35 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-direction-arrows",
|
||||
"type": "symbol",
|
||||
"source": "local-osm-lines",
|
||||
"source-layer": "planet_osm_line",
|
||||
"minzoom": 15,
|
||||
"filter": [
|
||||
"all",
|
||||
["in", "highway", "motorway", "trunk", "primary", "secondary", "tertiary", "residential", "unclassified", "living_street"],
|
||||
["==", "oneway", "yes"]
|
||||
],
|
||||
"layout": {
|
||||
"symbol-placement": "line",
|
||||
"symbol-spacing": 80,
|
||||
"text-field": "▸",
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": ["interpolate", ["linear"], ["zoom"], 15, 12, 18, 18],
|
||||
"text-keep-upright": false,
|
||||
"text-rotation-alignment": "map",
|
||||
"text-allow-overlap": true,
|
||||
"text-ignore-placement": true,
|
||||
"text-padding": 0
|
||||
},
|
||||
"paint": {
|
||||
"text-color": ["interpolate", ["linear"], ["zoom"], 15, "rgba(100,116,139,0.4)", 18, "rgba(100,116,139,0.7)"],
|
||||
"text-halo-color": "rgba(255,255,255,0.3)",
|
||||
"text-halo-width": 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "building-fill-flat",
|
||||
"type": "fill",
|
||||
@@ -938,120 +967,43 @@
|
||||
{
|
||||
"id": "building-3d",
|
||||
"type": "fill-extrusion",
|
||||
"source": "local-osm-polygons",
|
||||
"source-layer": "planet_osm_polygon",
|
||||
"source": "overture_buildings",
|
||||
"source-layer": "overture_building",
|
||||
"minzoom": 14,
|
||||
"layout": {
|
||||
"visibility": "none"
|
||||
"visibility": "visible"
|
||||
},
|
||||
"filter": [
|
||||
"has",
|
||||
"building"
|
||||
],
|
||||
"paint": {
|
||||
"fill-extrusion-color": [
|
||||
"match",
|
||||
[
|
||||
"get",
|
||||
"building"
|
||||
],
|
||||
"commercial",
|
||||
"#DDD5C5",
|
||||
"retail",
|
||||
"#E5D8C8",
|
||||
"industrial",
|
||||
"#D2D8E0",
|
||||
"church",
|
||||
"#DDD4EE",
|
||||
"mosque",
|
||||
"#CCE4D0",
|
||||
"hospital",
|
||||
"#EDD8D8",
|
||||
"school",
|
||||
"#E0E6CC",
|
||||
"university",
|
||||
"#D8E0C8",
|
||||
"hotel",
|
||||
"#D8DCF0",
|
||||
"apartments",
|
||||
"#E0DCD4",
|
||||
"#DDD8D2"
|
||||
],
|
||||
"fill-extrusion-color": "#DDD8D0",
|
||||
"fill-extrusion-height": [
|
||||
"interpolate",
|
||||
[
|
||||
"linear"
|
||||
],
|
||||
[
|
||||
"zoom"
|
||||
],
|
||||
["linear"],
|
||||
["zoom"],
|
||||
14,
|
||||
[
|
||||
"*",
|
||||
[
|
||||
"coalesce",
|
||||
[
|
||||
"to-number",
|
||||
[
|
||||
"get",
|
||||
"building:levels"
|
||||
],
|
||||
null
|
||||
],
|
||||
3
|
||||
],
|
||||
["coalesce", ["to-number", ["get", "num_floors"], null], 3],
|
||||
2.5
|
||||
],
|
||||
17,
|
||||
[
|
||||
"coalesce",
|
||||
[
|
||||
"to-number",
|
||||
[
|
||||
"get",
|
||||
"height"
|
||||
],
|
||||
null
|
||||
],
|
||||
["to-number", ["get", "height"], null],
|
||||
[
|
||||
"*",
|
||||
[
|
||||
"to-number",
|
||||
[
|
||||
"get",
|
||||
"building:levels"
|
||||
],
|
||||
3
|
||||
],
|
||||
["coalesce", ["to-number", ["get", "num_floors"], null], 3],
|
||||
3.5
|
||||
],
|
||||
12
|
||||
]
|
||||
],
|
||||
"fill-extrusion-base": [
|
||||
"coalesce",
|
||||
[
|
||||
"to-number",
|
||||
[
|
||||
"get",
|
||||
"min_height"
|
||||
],
|
||||
null
|
||||
],
|
||||
0
|
||||
],
|
||||
"fill-extrusion-base": 0,
|
||||
"fill-extrusion-opacity": [
|
||||
"interpolate",
|
||||
[
|
||||
"linear"
|
||||
],
|
||||
[
|
||||
"zoom"
|
||||
],
|
||||
14,
|
||||
0.6,
|
||||
16,
|
||||
0.88
|
||||
["linear"],
|
||||
["zoom"],
|
||||
14, 0.55,
|
||||
16, 0.85
|
||||
],
|
||||
"fill-extrusion-vertical-gradient": true
|
||||
}
|
||||
@@ -1222,33 +1174,28 @@
|
||||
"unclassified",
|
||||
"living_street"
|
||||
],
|
||||
"minzoom": 16,
|
||||
"minzoom": 15,
|
||||
"layout": {
|
||||
"text-field": [
|
||||
"coalesce",
|
||||
[
|
||||
"get",
|
||||
"name:ar"
|
||||
],
|
||||
[
|
||||
"get",
|
||||
"name"
|
||||
],
|
||||
["get", "name:ar"],
|
||||
["get", "name"],
|
||||
""
|
||||
],
|
||||
"text-font": [
|
||||
"Noto Sans Regular"
|
||||
],
|
||||
"text-size": 11,
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": ["interpolate", ["linear"], ["zoom"], 15, 10, 18, 13],
|
||||
"symbol-placement": "line",
|
||||
"text-letter-spacing": 0.04,
|
||||
"text-padding": 4,
|
||||
"text-allow-overlap": false
|
||||
"text-letter-spacing": 0.05,
|
||||
"text-padding": 15,
|
||||
"symbol-spacing": 300,
|
||||
"text-max-angle": 30,
|
||||
"text-allow-overlap": false,
|
||||
"text-ignore-placement": false
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#4A5568",
|
||||
"text-halo-color": "rgba(255,255,255,0.85)",
|
||||
"text-halo-width": 1.5
|
||||
"text-halo-color": "rgba(255,255,255,0.92)",
|
||||
"text-halo-width": 1.8
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1265,45 +1212,28 @@
|
||||
"motorway",
|
||||
"trunk"
|
||||
],
|
||||
"minzoom": 13,
|
||||
"minzoom": 12,
|
||||
"layout": {
|
||||
"text-field": [
|
||||
"coalesce",
|
||||
[
|
||||
"get",
|
||||
"name:ar"
|
||||
],
|
||||
[
|
||||
"get",
|
||||
"name"
|
||||
],
|
||||
["get", "name:ar"],
|
||||
["get", "name"],
|
||||
""
|
||||
],
|
||||
"text-font": [
|
||||
"Noto Sans Regular"
|
||||
],
|
||||
"text-size": [
|
||||
"interpolate",
|
||||
[
|
||||
"linear"
|
||||
],
|
||||
[
|
||||
"zoom"
|
||||
],
|
||||
13,
|
||||
11,
|
||||
16,
|
||||
14
|
||||
],
|
||||
"text-font": ["Noto Sans Bold"],
|
||||
"text-size": ["interpolate", ["linear"], ["zoom"], 12, 10, 14, 13, 18, 16],
|
||||
"symbol-placement": "line",
|
||||
"text-letter-spacing": 0.05,
|
||||
"text-padding": 5,
|
||||
"text-allow-overlap": false
|
||||
"text-letter-spacing": 0.06,
|
||||
"text-padding": 20,
|
||||
"symbol-spacing": 350,
|
||||
"text-max-angle": 25,
|
||||
"text-allow-overlap": false,
|
||||
"text-ignore-placement": false
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#2D3748",
|
||||
"text-halo-color": "rgba(255,255,255,0.9)",
|
||||
"text-halo-width": 2
|
||||
"text-color": "#1A2332",
|
||||
"text-halo-color": "rgba(255,255,255,0.95)",
|
||||
"text-halo-width": 2.5
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1929,14 +1859,11 @@
|
||||
"type": "fill",
|
||||
"source": "overture_buildings",
|
||||
"source-layer": "overture_building",
|
||||
"minzoom": 14,
|
||||
"layout": {
|
||||
"visibility": "visible"
|
||||
},
|
||||
"maxzoom": 14,
|
||||
"paint": {
|
||||
"fill-color": "#dcd8d0",
|
||||
"fill-opacity": 0.8,
|
||||
"fill-outline-color": "#c4beb4"
|
||||
"fill-color": "#DDD8D0",
|
||||
"fill-opacity": 0.85,
|
||||
"fill-outline-color": "#C4BEB4"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1978,31 +1905,26 @@
|
||||
"source": "overture_segments",
|
||||
"source-layer": "overture_segment",
|
||||
"minzoom": 14,
|
||||
"filter": ["has", "names"],
|
||||
"layout": {
|
||||
"text-field": [
|
||||
"coalesce",
|
||||
[
|
||||
"get",
|
||||
"names"
|
||||
],
|
||||
""
|
||||
],
|
||||
"text-font": [
|
||||
"Noto Sans Regular"
|
||||
],
|
||||
"text-size": 11,
|
||||
"text-field": ["coalesce", ["get", "names"], ""],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": ["interpolate", ["linear"], ["zoom"], 14, 9, 16, 12, 18, 14],
|
||||
"symbol-placement": "line",
|
||||
"text-padding": 10,
|
||||
"text-rotation-alignment": "map",
|
||||
"text-pitch-alignment": "viewport",
|
||||
"text-keep-upright": true,
|
||||
"text-padding": 20,
|
||||
"text-letter-spacing": 0.04,
|
||||
"text-max-angle": 30,
|
||||
"symbol-spacing": 250,
|
||||
"symbol-spacing": 300,
|
||||
"text-allow-overlap": false,
|
||||
"text-ignore-placement": false
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#666",
|
||||
"text-halo-color": "rgba(255, 255, 255, 0.9)",
|
||||
"text-halo-width": 2
|
||||
"text-color": "#4A5568",
|
||||
"text-halo-color": "rgba(255, 255, 255, 0.95)",
|
||||
"text-halo-width": 2.2
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -21,13 +21,17 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
||||
useEffect(() => {
|
||||
if (!map.current) return;
|
||||
|
||||
// Toggle 3D Buildings
|
||||
// Toggle 3D Buildings (now on Overture source)
|
||||
if (map.current.getLayer('building-3d')) {
|
||||
map.current.setLayoutProperty('building-3d', 'visibility', show3D ? 'visible' : 'none');
|
||||
}
|
||||
// Toggle flat Overture footprint (show when 3D is off)
|
||||
if (map.current.getLayer('overture-building-footprint')) {
|
||||
map.current.setLayoutProperty('overture-building-footprint', 'visibility', show3D ? 'none' : 'visible');
|
||||
}
|
||||
|
||||
// Toggle POI Layers
|
||||
const poiLayers = ['poi-icons', 'place-labels'];
|
||||
const poiLayers = ['poi-icons', 'place-labels', 'overture-building-names'];
|
||||
poiLayers.forEach(layerId => {
|
||||
if (map.current!.getLayer(layerId)) {
|
||||
map.current!.setLayoutProperty(layerId, 'visibility', showPOIs ? 'visible' : 'none');
|
||||
@@ -389,7 +393,7 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
||||
|
||||
return (
|
||||
<div ref={mapContainer} style={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
{/* Intaleq Branding Watermark */}
|
||||
{/* Intaleq Premium Branding Watermark */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -398,25 +402,29 @@ const MapComponent: React.FC<MapComponentProps> = ({
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.7)',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
background: 'rgba(255, 255, 255, 0.85)',
|
||||
backdropFilter: 'blur(12px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '10px',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
gap: '6px'
|
||||
gap: '8px',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.08)',
|
||||
border: '1px solid rgba(255,255,255,0.35)'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/intaleq-logo.png"
|
||||
alt="Intaleq"
|
||||
style={{ height: '16px', filter: 'drop-shadow(0 1px 1px rgba(0,0,0,0.1))' }}
|
||||
style={{ height: '18px', filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.1))' }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
color: '#3c4043',
|
||||
letterSpacing: '0.02em',
|
||||
fontWeight: 800,
|
||||
color: '#c0a048',
|
||||
letterSpacing: '0.8px',
|
||||
textTransform: 'uppercase'
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import os
|
||||
|
||||
def export_minimalist_project():
|
||||
output_filename = "project_source_code_light.txt"
|
||||
|
||||
# 1. الملفات المسموح بها بالاسم الكامل (حساسة لحالة الأحرف)
|
||||
strictly_allowed_names = {'style.json', 'style_mobile.json', '.env', 'env'}
|
||||
|
||||
# 2. الامتدادات البرمجية المسموح بها (الكود المصدري فقط)
|
||||
allowed_logic_extensions = {'.ts', '.sh'}
|
||||
|
||||
# 3. مجلدات يتم تجاوزها فوراً لتسريع العملية
|
||||
ignore_dirs = {'node_modules', 'dist', '.git', 'coverage', 'build', '.idea', '.vscode'}
|
||||
|
||||
file_counter = 0
|
||||
|
||||
print("جاري استخراج الملفات المطلوبة فقط (Light Mode)...")
|
||||
|
||||
with open(output_filename, 'w', encoding='utf-8') as outfile:
|
||||
outfile.write("=" * 60 + "\n")
|
||||
outfile.write("ملفات المشروع المختارة (Style JSON + Source Code)\n")
|
||||
outfile.write("=" * 60 + "\n\n")
|
||||
|
||||
for root, dirs, files in os.walk('.'):
|
||||
# استثناء المجلدات الثقيلة
|
||||
dirs[:] = [d for d in dirs if d not in ignore_dirs]
|
||||
|
||||
for file in files:
|
||||
name_lower = file.lower()
|
||||
ext = os.path.splitext(name_lower)[1]
|
||||
|
||||
# شرط القبول المنطقي:
|
||||
# - إما أن يكون الاسم مطابقاً تماماً لملفات الستايل أو env
|
||||
# - أو أن يكون امتداد ملف كود (ts, sh)
|
||||
if file in strictly_allowed_names or ext in allowed_logic_extensions:
|
||||
|
||||
# استثناء إضافي لملفات الاختبار أو التكوين الفرعية لتقليل الحجم
|
||||
if '.spec.ts' in name_lower or 'test.ts' in name_lower:
|
||||
continue
|
||||
|
||||
filepath = os.path.join(root, file)
|
||||
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
file_counter += 1
|
||||
outfile.write("-" * 60 + "\n")
|
||||
outfile.write(f"[{file_counter}] اسم الملف: {file}\n")
|
||||
outfile.write(f"المسار: {filepath}\n")
|
||||
outfile.write("-" * 60 + "\n")
|
||||
|
||||
for line_number, line in enumerate(lines, 1):
|
||||
# كتابة رقم السطر مع الكود
|
||||
outfile.write(f"{line_number:04d} | {line.rstrip()}\n")
|
||||
|
||||
outfile.write("\n\n")
|
||||
print(f"تمت الإضافة: {filepath}")
|
||||
|
||||
except Exception:
|
||||
# تجاوز أي ملف يسبب خطأ في القراءة
|
||||
continue
|
||||
|
||||
print(f"\n✅ اكتملت العملية بنجاح.")
|
||||
print(f"إجمالي الملفات المضافة: {file_counter}")
|
||||
print(f"الملف الناتج: {output_filename}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
export_minimalist_project()
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user