feat: add road refinement controller and HERE maps in CompareView
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user