feat: add road refinement controller and HERE maps in CompareView

This commit is contained in:
Hamza-Ayed
2026-08-16 13:35:09 +03:00
parent 3136a2b7aa
commit 8061f3e7a3
3 changed files with 317 additions and 9 deletions
+3 -1
View File
@@ -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],
})
@@ -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<CandidateRoad>,
@InjectRepository(RoadSegmentStat)
private readonly roadStatRepo: Repository<RoadSegmentStat>,
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 };
}
}