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 };
|
||||
}
|
||||
}
|
||||
@@ -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<RefKey, { label: string; tiles: string; attribution: string; maxzoom: number }> = {
|
||||
'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<RefKey, { label: string; tiles: string; attribution: string; maxzoom: number }> => ({
|
||||
'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<maplibregl.Map | null>(null);
|
||||
const syncing = useRef(false);
|
||||
|
||||
const [hereApiKey, setHereApiKey] = useState<string>(() => {
|
||||
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<RefKey>('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 = () => {
|
||||
<strong style={{ fontSize: '0.9rem' }}>Compare</strong>
|
||||
<span style={{ color: '#64748b', fontSize: '0.78rem' }}>Our map ⟷ reference — synced</span>
|
||||
|
||||
<div style={{ display: 'flex', gap: '6px', marginInlineStart: 'auto' }}>
|
||||
<div style={{ display: 'flex', gap: '6px', marginInlineStart: 'auto', flexWrap: 'wrap' }}>
|
||||
{(Object.keys(REFS) as RefKey[]).map(k => (
|
||||
<button key={k} onClick={() => setRefKey(k)}
|
||||
style={{ ...btn, ...(refKey === k ? { borderColor: '#6366f1', color: '#fff', background: 'rgba(99,102,241,0.2)' } : {}) }}>
|
||||
{REFS[k].label}
|
||||
</button>
|
||||
))}
|
||||
{refKey.startsWith('here') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setTempKey(hereApiKey);
|
||||
setShowKeyModal(true);
|
||||
}}
|
||||
style={{ ...btn, borderColor: '#f59e0b', color: '#fcd34d', background: 'rgba(245,158,11,0.1)' }}
|
||||
title="Configure HERE API Key"
|
||||
>
|
||||
🔑 {hereApiKey ? 'HERE Key Configured' : 'Set HERE Key'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HERE API Key Modal */}
|
||||
{showKeyModal && (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 999, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ background: '#1e293b', padding: '20px', borderRadius: '12px', width: '90%', maxWidth: '440px', border: '1px solid #334155', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1rem', color: '#f8fafc' }}>🔑 HERE Platform API Key</h3>
|
||||
<p style={{ margin: 0, fontSize: '0.8rem', color: '#94a3b8', lineHeight: 1.4 }}>
|
||||
Enter your official HERE Raster Tile API v3 key to view official HERE Satellite & Streets imagery.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Paste your HERE apiKey here..."
|
||||
value={tempKey}
|
||||
onChange={(e) => 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' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '6px' }}>
|
||||
<button
|
||||
onClick={() => setShowKeyModal(false)}
|
||||
style={{ ...btn, background: 'transparent', borderColor: '#475569' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const clean = tempKey.trim();
|
||||
setHereApiKey(clean);
|
||||
if (clean) {
|
||||
localStorage.setItem('here_api_key', clean);
|
||||
} else {
|
||||
localStorage.removeItem('here_api_key');
|
||||
}
|
||||
setShowKeyModal(false);
|
||||
}}
|
||||
style={{ ...btn, background: '#6366f1', color: '#fff', borderColor: '#6366f1', fontWeight: 600 }}
|
||||
>
|
||||
Save Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Maps */}
|
||||
<div style={{ position: 'relative', flex: 1, display: 'flex' }}>
|
||||
<div style={{ position: 'relative', flex: 1, borderInlineEnd: '2px solid #0f172a' }}>
|
||||
@@ -135,6 +201,12 @@ const CompareView: React.FC = () => {
|
||||
<div style={{ position: 'relative', flex: 1 }}>
|
||||
<div ref={rightDiv} style={{ position: 'absolute', inset: 0 }} />
|
||||
<Badge text={REFS[refKey].label} />
|
||||
{refKey.startsWith('here') && !hereApiKey && (
|
||||
<div style={{ position: 'absolute', bottom: 30, left: '50%', transform: 'translateX(-50%)', zIndex: 10, background: 'rgba(15,23,42,0.92)', border: '1px solid #f59e0b', padding: '8px 14px', borderRadius: '8px', fontSize: '0.75rem', color: '#fbbf24', display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}
|
||||
onClick={() => setShowKeyModal(true)}>
|
||||
<span>⚠️ Click here to enter your HERE API Key</span>
|
||||
</div>
|
||||
)}
|
||||
<Crosshair />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user