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 };
}
}
+80 -8
View File
@@ -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>