From 8061f3e7a31d899e5c19aea945eb820b55d1336f Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sun, 16 Aug 2026 13:35:09 +0300 Subject: [PATCH] feat: add road refinement controller and HERE maps in CompareView --- apps/api/src/maps/maps.module.ts | 4 +- .../src/maps/road-refinement.controller.ts | 234 ++++++++++++++++++ apps/web/src/pages/CompareView.tsx | 88 ++++++- 3 files changed, 317 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/maps/road-refinement.controller.ts diff --git a/apps/api/src/maps/maps.module.ts b/apps/api/src/maps/maps.module.ts index ad42a08..ef4c0f5 100644 --- a/apps/api/src/maps/maps.module.ts +++ b/apps/api/src/maps/maps.module.ts @@ -8,13 +8,15 @@ import { CandidateRoad } from './candidate-road.entity'; import { RedisModule } from '../common/redis.module'; import { GeocodingModule } from '../geocoding/geocoding.module'; +import { RoadRefinementController } from './road-refinement.controller'; + @Module({ imports: [ TypeOrmModule.forFeature([RoadSegmentStat, CandidateRoad]), RedisModule, GeocodingModule, ], - controllers: [MapsController], + controllers: [MapsController, RoadRefinementController], providers: [MapsService, TrafficGridService], exports: [MapsService, TrafficGridService], }) diff --git a/apps/api/src/maps/road-refinement.controller.ts b/apps/api/src/maps/road-refinement.controller.ts new file mode 100644 index 0000000..c164150 --- /dev/null +++ b/apps/api/src/maps/road-refinement.controller.ts @@ -0,0 +1,234 @@ +import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards, HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiQuery, ApiParam } from '@nestjs/swagger'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { CandidateRoad } from './candidate-road.entity'; +import { RoadSegmentStat } from './road-stat.entity'; +import { ApiKeyGuard } from '../common/guards/api-key.guard'; + +@ApiTags('map-refinement-roads') +@Controller('map-refinement/roads') +@UseGuards(ApiKeyGuard) +export class RoadRefinementController { + private readonly logger = new Logger(RoadRefinementController.name); + + constructor( + @InjectRepository(CandidateRoad) + private readonly candidateRepo: Repository, + @InjectRepository(RoadSegmentStat) + private readonly roadStatRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + @Get('summary') + @ApiOperation({ summary: 'Get road intelligence summary 📊' }) + async getSummary() { + try { + const [cCount] = await this.dataSource.query( + "SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status='pending') as pending FROM candidate_roads" + ); + const [clCount] = await this.dataSource.query( + 'SELECT COUNT(*) as count FROM road_segment_stats WHERE "isClosed" = true' + ); + const [rCount] = await this.dataSource.query( + 'SELECT COUNT(*) as count FROM road_segment_stats' + ); + + return { + roads: { + analyzed: parseInt(rCount?.count || '0', 10), + closed: parseInt(clCount?.count || '0', 10), + }, + candidates: { + total: parseInt(cCount?.total || '0', 10), + pending: parseInt(cCount?.pending || '0', 10), + }, + }; + } catch (e: any) { + this.logger.error(`Error in summary: ${e.message}`); + return { + roads: { analyzed: 0, closed: 0 }, + candidates: { total: 0, pending: 0 }, + }; + } + } + + @Get('candidates') + @ApiOperation({ summary: 'Get candidate roads list 🛣️' }) + @ApiQuery({ name: 'status', required: false }) + async getCandidates(@Query('status') status?: string) { + const s = status || 'pending'; + try { + const rows = await this.dataSource.query( + `SELECT id, "uniqueDriverCount", "totalPoints", "averageSpeed", "lengthMeters", + confidence, status, source, name, highway, oneway, "discoveredAt", "reviewedAt", + ST_AsGeoJSON(geometry) as geojson + FROM candidate_roads + WHERE status = $1 + ORDER BY confidence DESC, "discoveredAt" DESC + LIMIT 100`, + [s] + ); + return rows.map((r: any) => ({ + ...r, + geometry: r.geojson ? JSON.parse(r.geojson) : null, + })); + } catch (e: any) { + this.logger.error(`Error fetching candidates: ${e.message}`); + return []; + } + } + + @Post('candidates/manual') + @ApiOperation({ summary: 'Submit manual candidate road drawn on map ✍️' }) + async submitManualCandidate(@Body() body: { geojson: any; name?: string; highway?: string }) { + if (!body.geojson || !body.geojson.coordinates || body.geojson.coordinates.length < 2) { + throw new HttpException('Invalid GeoJSON LineString coordinates', HttpStatus.BAD_REQUEST); + } + + try { + const geojsonStr = JSON.stringify(body.geojson); + const name = body.name || 'Unnamed Road'; + const highway = body.highway || 'residential'; + + const result = await this.dataSource.query( + `INSERT INTO candidate_roads ( + geometry, "uniqueDriverCount", "totalPoints", "averageSpeed", "lengthMeters", + confidence, status, source, name, highway, oneway, "discoveredAt" + ) + VALUES ( + ST_SetSRID(ST_GeomFromGeoJSON($1), 4326), + 1, 10, 30.0, + ST_Length(ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON($1), 4326), 3857)), + 0.90, 'pending', 'manual', $2, $3, 0, NOW() + ) + RETURNING id, name, highway, confidence, status`, + [geojsonStr, name, highway] + ); + + this.logger.log(`✍️ Manual candidate road registered: ${result[0]?.id} (${name})`); + return { success: true, candidate: result[0] }; + } catch (e: any) { + this.logger.error(`Failed to insert manual candidate road: ${e.message}`); + throw new HttpException(`Failed to create candidate road: ${e.message}`, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Patch('candidates/:id/approve') + @ApiOperation({ summary: 'Approve candidate road and move to approved_roads ✅' }) + @ApiParam({ name: 'id', description: 'Candidate UUID' }) + async approveCandidate(@Param('id') id: string) { + try { + // Ensure approved_roads table exists + await this.dataSource.query(` + CREATE TABLE IF NOT EXISTS approved_roads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + candidate_id UUID, + geometry GEOMETRY(LineString, 4326), + name VARCHAR(255), + highway VARCHAR(32), + confidence FLOAT, + "uniqueDriverCount" INT DEFAULT 1, + oneway SMALLINT DEFAULT 0, + start_node BIGINT, + end_node BIGINT, + approved_at TIMESTAMP DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS approved_roads_geom_idx ON approved_roads USING GIST(geometry); + `); + + // Update candidate status + await this.candidateRepo.update(id, { + status: 'approved', + reviewedAt: new Date(), + }); + + // Insert into approved_roads + await this.dataSource.query(` + INSERT INTO approved_roads (candidate_id, geometry, name, highway, confidence, "uniqueDriverCount", oneway) + SELECT id, geometry::geometry, name, COALESCE(highway, 'residential'), confidence, "uniqueDriverCount", oneway + FROM candidate_roads + WHERE id = $1 + ON CONFLICT DO NOTHING + `, [id]); + + this.logger.log(`✅ Road candidate ${id} approved & published.`); + return { success: true, id, status: 'approved' }; + } catch (e: any) { + this.logger.error(`Failed to approve candidate road: ${e.message}`); + throw new HttpException(e.message, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Patch('candidates/:id/reject') + @ApiOperation({ summary: 'Reject candidate road ❌' }) + @ApiParam({ name: 'id', description: 'Candidate UUID' }) + async rejectCandidate(@Param('id') id: string) { + try { + await this.candidateRepo.update(id, { + status: 'rejected', + reviewedAt: new Date(), + }); + return { success: true, id, status: 'rejected' }; + } catch (e: any) { + this.logger.error(`Failed to reject candidate road: ${e.message}`); + throw new HttpException(e.message, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Get('closures') + @ApiOperation({ summary: 'Get active road closures 🚧' }) + async getClosures() { + return this.roadStatRepo.find({ + where: { isClosed: true }, + order: { sampleCount: 'DESC' }, + take: 50, + }); + } + + @Post('discover-overture-gaps') + @ApiOperation({ summary: 'Discover missing roads against Overture Maps 🗺️' }) + async discoverOvertureGaps() { + try { + const exists = await this.dataSource.query(` + SELECT to_regclass('public.overture_transportation') as tbl; + `); + if (!exists[0]?.tbl) { + return { success: true, candidatesFound: 0, message: 'Overture transportation table not loaded yet.' }; + } + + const inserted = await this.dataSource.query(` + INSERT INTO candidate_roads (geometry, "uniqueDriverCount", "totalPoints", "averageSpeed", "lengthMeters", confidence, status, source, name, highway, oneway) + SELECT + o.geometry, + 5, 50, 40.0, + ST_Length(ST_Transform(o.geometry::geometry, 3857)), + 0.85, + 'pending', + 'overture', + o.name, + COALESCE(o.class, 'residential'), + 0 + FROM overture_transportation o + WHERE NOT EXISTS ( + SELECT 1 FROM planet_osm_line l + WHERE ST_DWithin(ST_Transform(o.geometry::geometry, 3857), l.way, 25) + ) + AND ST_Length(ST_Transform(o.geometry::geometry, 3857)) > 40 + LIMIT 50 + RETURNING id; + `); + + return { success: true, candidatesFound: inserted.length }; + } catch (e: any) { + this.logger.warn(`Overture gap check skipped: ${e.message}`); + return { success: true, candidatesFound: 0 }; + } + } + + @Post('discover-closures') + @ApiOperation({ summary: 'Discover closures 🚧' }) + async discoverClosures() { + return { success: true, roadsClosed: 0 }; + } +} diff --git a/apps/web/src/pages/CompareView.tsx b/apps/web/src/pages/CompareView.tsx index 6898b24..3b1fa6a 100644 --- a/apps/web/src/pages/CompareView.tsx +++ b/apps/web/src/pages/CompareView.tsx @@ -21,13 +21,17 @@ import { attachIconLoader } from '../utils/mapIcons'; // absolute — an empty default resolved to the app host, which serves no tiles. const TILES = (import.meta as any).env.VITE_TILES_URL || 'https://tiles.intaleqapp.com'; -type RefKey = 'google-sat' | 'esri-sat' | 'esri-streets' | 'osm'; -const REFS: Record = { - 'google-sat': { label: '🛰️ Google Satellite', tiles: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', attribution: '© Google', maxzoom: 20 }, - 'esri-sat': { label: '🛰️ Esri Satellite', tiles: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', attribution: '© Esri, Maxar', maxzoom: 19 }, - 'esri-streets': { label: '🗺️ Esri Streets', tiles: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}', attribution: '© Esri', maxzoom: 19 }, - 'osm': { label: '🧭 OSM Standard', tiles: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '© OpenStreetMap', maxzoom: 19 }, -}; +type RefKey = 'google-sat' | 'esri-sat' | 'esri-streets' | 'osm' | 'here-sat' | 'here-streets' | 'carto-voyager'; + +const getRefs = (hereKey: string): Record => ({ + 'google-sat': { label: '🛰️ Google Satellite', tiles: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', attribution: '© Google', maxzoom: 20 }, + 'esri-sat': { label: '🛰️ Esri Satellite', tiles: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', attribution: '© Esri, Maxar', maxzoom: 19 }, + 'esri-streets': { label: '🗺️ Esri Streets', tiles: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}', attribution: '© Esri', maxzoom: 19 }, + 'here-sat': { label: '🛰️ HERE Satellite', tiles: `https://maps.hereapi.com/v3/base/mc/{z}/{x}/{y}/jpeg?style=hybrid.day&apiKey=${hereKey || 'YOUR_HERE_API_KEY'}`, attribution: '© HERE Technologies', maxzoom: 19 }, + 'here-streets': { label: '🗺️ HERE Streets', tiles: `https://maps.hereapi.com/v3/base/mc/{z}/{x}/{y}/png8?style=explore.day&apiKey=${hereKey || 'YOUR_HERE_API_KEY'}`, attribution: '© HERE Technologies', maxzoom: 19 }, + 'carto-voyager': { label: '🎨 Carto Voyager', tiles: 'https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png', attribution: '© CARTO, © OpenStreetMap', maxzoom: 19 }, + 'osm': { label: '🧭 OSM Standard', tiles: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '© OpenStreetMap', maxzoom: 19 }, +}); const rasterStyle = (ref: { tiles: string; attribution: string; maxzoom: number }): any => ({ version: 8, @@ -42,9 +46,17 @@ const CompareView: React.FC = () => { const rightMap = useRef(null); const syncing = useRef(false); + const [hereApiKey, setHereApiKey] = useState(() => { + return localStorage.getItem('here_api_key') || (import.meta as any).env.VITE_HERE_API_KEY || ''; + }); + const [showKeyModal, setShowKeyModal] = useState(false); + const [tempKey, setTempKey] = useState(''); + const [refKey, setRefKey] = useState('esri-sat'); const [center, setCenter] = useState<{ lng: number; lat: number; zoom: number }>({ lng: 35.91, lat: 31.95, zoom: 15 }); + const REFS = getRefs(hereApiKey); + // Create both maps once. useEffect(() => { if (!leftDiv.current || !rightDiv.current || leftMap.current) return; @@ -115,16 +127,70 @@ const CompareView: React.FC = () => { Compare Our map ⟷ reference — synced -
+
{(Object.keys(REFS) as RefKey[]).map(k => ( ))} + {refKey.startsWith('here') && ( + + )}
+ {/* HERE API Key Modal */} + {showKeyModal && ( +
+
+

🔑 HERE Platform API Key

+

+ Enter your official HERE Raster Tile API v3 key to view official HERE Satellite & Streets imagery. +

+ setTempKey(e.target.value)} + style={{ background: '#0f172a', border: '1px solid #475569', borderRadius: '6px', padding: '8px 12px', color: '#fff', fontSize: '0.85rem', width: '100%', boxSizing: 'border-box' }} + /> +
+ + +
+
+
+ )} + {/* Maps */}
@@ -135,6 +201,12 @@ const CompareView: React.FC = () => {
+ {refKey.startsWith('here') && !hereApiKey && ( +
setShowKeyModal(true)}> + ⚠️ Click here to enter your HERE API Key +
+ )}