@@ -96,6 +96,32 @@ function pointAtChainage(v: MapVideo, cum: number[], m: number): [number, number
] ;
}
/** Speed bucket color (km/h): blue slow → red fast, colorblind-tolerant ramp. */
function speedColor ( mps : number ) : string {
const k = mps * 3.6 ;
return k < 20 ? "#4575b4" : k < 40 ? "#74add1" : k < 60 ? "#5ccf75" : k < 80 ? "#f5c838" : k < 100 ? "#f5a623" : "#ff5252" ;
}
const SPEED_LEGEND : [ string , string ] [ ] = [
[ "<20" , "#4575b4" ] , [ "20– 40" , "#74add1" ] , [ "40– 60" , "#5ccf75" ] ,
[ "60– 80" , "#f5c838" ] , [ "80– 100" , "#f5a623" ] , [ "100+" , "#ff5252" ] ,
] ;
/** Bearing (deg, 0 = north) from a → b, for the direction arrows. */
function bearingDeg ( a : [ number , number , number ] , b : [ number , number , number ] ) : number {
const dLat = b [ 0 ] - a [ 0 ] ;
const dLon = ( b [ 1 ] - a [ 1 ] ) * Math . cos ( ( a [ 0 ] * Math . PI ) / 180 ) ;
return ( Math . atan2 ( dLon , dLat ) * 180 ) / Math . PI ;
}
/** A coverage finding between two chainage-adjacent videos of the same group. */
interface CoverageItem {
kind : "gap" | "overlap" ;
a : MapVideo ;
b : MapVideo ;
size_m : number ;
pos : [ number , number ] | null ;
}
/** "12+400" (km+m) or "12.4"/"12.4 km" → meters; null when the query isn't a chainage. */
function parseChainageQuery ( q : string ) : number | null {
const t = q . trim ( ) ;
@@ -135,7 +161,10 @@ export function MapView({
} ) {
const [ videos , setVideos ] = useState < MapVideo [ ] > ( [ ] ) ;
const [ points , setPoints ] = useState < ChainagePoint [ ] > ( [ ] ) ;
const [ colorBy , setColorBy ] = useState < "video" | "status" > ( "video" ) ;
const [ colorBy , setColorBy ] = useState < "video" | "status" | "speed" > ( "video" ) ;
const [ basemap , setBasemap ] = useState < "map" | "sat" > ( "map" ) ;
const [ roadFilter , setRoadFilter ] = useState < string | null > ( null ) ; // null = all, '' = unassigned
const [ showCoverage , setShowCoverage ] = useState ( true ) ;
const [ hidden , setHidden ] = useState < Set < string > > ( new Set ( ) ) ;
const [ search , setSearch ] = useState ( "" ) ;
const [ selectedId , setSelectedId ] = useState < number | null > ( null ) ;
@@ -151,6 +180,8 @@ export function MapView({
const pointLayer = useRef < L.LayerGroup | null > ( null ) ;
const correctionLayer = useRef < L.LayerGroup | null > ( null ) ;
const searchLayer = useRef < L.LayerGroup | null > ( null ) ;
const coverageLayer = useRef < L.LayerGroup | null > ( null ) ;
const baseLayers = useRef < { map : L.TileLayer ; sat : L.TileLayer } | null > ( null ) ;
const fitted = useRef ( false ) ;
// Per-video cumulative track distances, computed lazily for hover/search readouts.
const cumCache = useRef ( new Map < number , number [ ] > ( ) ) ;
@@ -196,14 +227,23 @@ export function MapView({
// ---- Leaflet init (once) ----
useEffect ( ( ) = > {
if ( ! mapDiv . current || mapRef . current ) return ;
const map = L . map ( mapDiv . current , { center : [ 20 , 45 ] , zoom : 5 } ) ;
// OSM raster tiles (internet). The basemap is an optional layer — tracks render
// fine even when tiles can't load (offline VM).
L . tileLayer ( "https://tile.openstreetmap.org/{z}/{x}/{y}.png" , {
maxZoom : 19 ,
const map = L . map ( mapDiv . current , { center : [ 20 , 45 ] , zoom : 5 , maxZoom : 22 } ) ;
// Two basemaps (both optional — tracks render fine when tiles can't load):
// OSM streets and Esri satellite. maxNativeZoom 19 + maxZoom 22 over-zooms the
// deepest tiles so you can inspect a single junction ("deep zoom").
const osm = L . tileLayer ( "https://tile.openstreetmap.org/{z}/{x}/{y}.png" , {
maxZoom : 22 ,
maxNativeZoom : 19 ,
attribution : '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' ,
} ) . addTo ( map ) ;
} ) ;
const sat = L . tileLayer (
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" ,
{ maxZoom : 22 , maxNativeZoom : 19 , attribution : "Esri, Maxar, Earthstar Geographics" } ,
) ;
osm . addTo ( map ) ;
baseLayers . current = { map : osm , sat } ;
trackLayer . current = L . layerGroup ( ) . addTo ( map ) ;
coverageLayer . current = L . layerGroup ( ) . addTo ( map ) ;
pointLayer . current = L . layerGroup ( ) . addTo ( map ) ;
correctionLayer . current = L . layerGroup ( ) . addTo ( map ) ;
searchLayer . current = L . layerGroup ( ) . addTo ( map ) ;
@@ -212,14 +252,36 @@ export function MapView({
return ( ) = > {
map . remove ( ) ;
mapRef . current = null ;
baseLayers . current = null ;
} ;
} , [ ] ) ;
// Swap the basemap (streets ↔ satellite).
useEffect ( ( ) = > {
const map = mapRef . current ;
const bl = baseLayers . current ;
if ( ! map || ! bl ) return ;
const on = basemap === "sat" ? bl.sat : bl.map ;
const off = basemap === "sat" ? bl.map : bl.sat ;
if ( map . hasLayer ( off ) ) map . removeLayer ( off ) ;
if ( ! map . hasLayer ( on ) ) on . addTo ( map ) ;
} , [ basemap ] ) ;
useEffect ( ( ) = > cumCache . current . clear ( ) , [ videos ] ) ;
const withTrack = useMemo ( ( ) = > videos . filter ( ( v ) = > v . track && v . track . length >= 2 ) , [ videos ] ) ;
const noMeta = useMemo ( ( ) = > videos . filter ( ( v ) = > v . gps_status === "none" || v . gps_status === "error" ) , [ videos ] ) ;
const pendingScan = useMemo ( ( ) = > videos . filter ( ( v ) = > v . gps_status === "pending" ) , [ videos ] ) ;
/** Idle = GPS fine but the vehicle barely moved (parked/idling footage). */
const idle = useMemo (
( ) = > videos . filter ( ( v ) = > v . gps_status === "ok" && ( v . gps_len_m ? ? 0 ) < 50 ) ,
[ videos ] ,
) ;
/** Long GPS dropouts inside otherwise-fine videos. */
const dropouts = useMemo (
( ) = > videos . filter ( ( v ) = > v . gps_status === "ok" && ( v . gps_max_gap_s ? ? 0 ) > 30 && ( v . gps_len_m ? ? 0 ) >= 50 ) ,
[ videos ] ,
) ;
const statusCounts = useMemo ( ( ) = > {
const c : Record < string , number > = { } ;
@@ -227,11 +289,60 @@ export function MapView({
return c ;
} , [ withTrack ] ) ;
/** Road types present in this project's map data ('' = unassigned exists). */
const roadTypesPresent = useMemo ( ( ) = > {
const s = new Set < string > ( ) ;
for ( const v of videos ) s . add ( v . road_type ) ;
return [ . . . s ] . sort ( ) ;
} , [ videos ] ) ;
const visible = useMemo (
( ) = > withTrack . filter ( ( v ) = > ! hidden . has ( mapStatus ( v ) ) ) ,
[ withTrack , hidden ] ,
( ) = >
withTrack . filter (
( v ) = > ! hidden . has ( mapStatus ( v ) ) && ( roadFilter === null || v . road_type === roadFilter ) ,
) ,
[ withTrack , hidden , roadFilter ] ,
) ;
// Coverage analysis: within each calibrated road-type group, sort videos by
// chainage and flag holes (>30 m uncovered) and overlaps (>30 m double-covered)
// between chainage-adjacent videos.
const coverage = useMemo ( ( ) = > {
const out : CoverageItem [ ] = [ ] ;
const groups = new Map < string , MapVideo [ ] > ( ) ;
for ( const v of videos ) {
if ( v . ignored || v . chainage_start_m == null || v . chainage_end_m == null ) continue ;
const g = groups . get ( v . road_type ) ? ? [ ] ;
g . push ( v ) ;
groups . set ( v . road_type , g ) ;
}
for ( const vs of groups . values ( ) ) {
vs . sort (
( x , y ) = >
Math . min ( x . chainage_start_m ! , x . chainage_end_m ! ) - Math . min ( y . chainage_start_m ! , y . chainage_end_m ! ) ,
) ;
for ( let i = 0 ; i + 1 < vs . length ; i ++ ) {
const a = vs [ i ] ;
const b = vs [ i + 1 ] ;
const aMax = Math . max ( a . chainage_start_m ! , a . chainage_end_m ! ) ;
const bMin = Math . min ( b . chainage_start_m ! , b . chainage_end_m ! ) ;
const bMax = Math . max ( b . chainage_start_m ! , b . chainage_end_m ! ) ;
const at = ( v : MapVideo , m : number ) = > {
const cum = cumFor ( v ) ;
return cum ? pointAtChainage ( v , cum , m ) : null ;
} ;
if ( bMin - aMax > 30 ) {
out . push ( { kind : "gap" , a , b , size_m : bMin - aMax , pos : at ( a , aMax ) ? ? at ( b , bMin ) } ) ;
} else if ( aMax - bMin > 30 ) {
const size = Math . min ( aMax , bMax ) - bMin ;
out . push ( { kind : "overlap" , a , b , size_m : size , pos : at ( b , bMin + size / 2 ) ? ? at ( a , aMax - size / 2 ) } ) ;
}
}
}
return out . sort ( ( x , y ) = > y . size_m - x . size_m ) ;
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ videos ] ) ;
// Search matches by name, by chainage ("12+400" / "12.4" — EVERY calibrated group
// containing it: LHS, RHS and a ramp can each match the same chainage), or by
// coordinates ("21.1055, 78.1857" — nearby tracks + a bare go-to-location entry).
@@ -248,9 +359,16 @@ export function MapView({
. slice ( 0 , 10 ) ;
return [ { v : null , ll } as SearchMatch , . . . near ] ;
}
const lq = q . toLowerCase ( ) ;
const byName : SearchMatch [ ] = videos
. filter ( ( v ) = > v . file_name . toLowerCase ( ) . includes ( lq ) || v . rel_path . toLowerCase ( ) . includes ( lq ) )
. slice ( 0 , 10 )
. map ( ( v ) : SearchMatch = > ( { v } ) ) ;
// A bare number is ambiguous: file names start with dates ("2026…") AND it could
// be a chainage — show name matches first, chainage hits after.
const m = parseChainageQuery ( q ) ;
if ( m != null ) {
return videos
const byCh : SearchMatch [ ] = videos
. filter ( ( v ) = > {
if ( v . chainage_start_m == null || v . chainage_end_m == null ) return false ;
const lo = Math . min ( v . chainage_start_m , v . chainage_end_m ) ;
@@ -260,12 +378,9 @@ export function MapView({
. sort ( ( a , b ) = > a . road_type . localeCompare ( b . road_type ) || a . file_name . localeCompare ( b . file_name ) )
. slice ( 0 , 12 )
. map ( ( v ) : SearchMatch = > ( { v , ch : m } ) ) ;
return [ . . . byName , . . . byCh ] . slice ( 0 , 14 ) ;
}
const lq = q . toLowerCase ( ) ;
return videos
. filter ( ( v ) = > v . file_name . toLowerCase ( ) . includes ( lq ) || v . rel_path . toLowerCase ( ) . includes ( lq ) )
. slice ( 0 , 12 )
. map ( ( v ) : SearchMatch = > ( { v } ) ) ;
return byName ;
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ videos , search ] ) ;
@@ -291,7 +406,7 @@ export function MapView({
const pt = cum ? pointAtChainage ( v , cum , m ) : null ;
if ( pt && mapRef . current && searchLayer . current ) {
L . circleMarker ( pt , { radius : 9 , color : "#ff3b3b" , weight : 3 , fillOpacity : 0.15 } )
. bindTooltip ( ` ${ ( m / 1000 ) . toFixed ( 3 ) } km `, { permanent : true , direction : "top" } )
. bindTooltip ( ` ${ ( m / 1000 ) . toFixed ( 4 ) } ` , { permanent : true , direction : "top" } )
. addTo ( searchLayer . current ) ;
mapRef . current . setView ( pt , 16 ) ;
}
@@ -318,35 +433,114 @@ export function MapView({
for ( const v of visible ) {
if ( correction && correction . videoId === v . id ) continue ; // overlay owns this one
const sel = v . id === selectedId ;
const color = colorBy === "video" ? videoColor ( v . id ) : STATUS_COLORS [ mapStatus ( v ) ] ? ? "#999" ;
const line = L . polyline ( latlngs ( v . track ! ) , {
color,
weight : sel ? 6 : 3 ,
opacity : sel ? 1 : 0.8 ,
dashArray : v.ignored ? "6 6" : undefined ,
} ) ;
line . bindTooltip ( ` ${ v . file_name } · ${ statusLabel ( mapStatus ( v ) ) } ` , { sticky : true } ) ;
const cum = cumFor ( v ) ;
if ( cum && v . chainage_start_m != null ) {
// Live chainage readout under the cursor.
line . on ( "mousemove" , ( e : L.LeafletMouseEvent ) = > {
const ch = chainageAtCursor ( v , cum , e . latlng ) ;
line . setTooltipContent (
` ${ v . file_name } · ${ statusLabel ( mapStatus ( v ) ) } ${ ch != null ? ` · ${ ( ch / 1000 ) . toFixed ( 3 ) } km ` : "" } ` ,
) ;
const color =
colorBy === "video" ? videoColor ( v . id )
: colorBy === "speed" ? ( v . speed_avg_mps != null ? speedColor ( v . speed_avg_mps ) : "#8f95ae" )
: STATUS_COLORS [ mapStatus ( v ) ] ? ? "#999" ;
const attachHandlers = ( line : L.Polyline ) = > {
line . bindTooltip ( ` ${ v . file_name } · ${ statusLabel ( mapStatus ( v ) ) } ` , { sticky : true } ) ;
const cum = cumFor ( v ) ;
if ( cum && v . chainage_start_m != null ) {
// Live chainage readout under the cursor.
line . on ( "mousemove" , ( e : L.LeafletMouseEvent ) = > {
const ch = chainageAtCursor ( v , cum , e . latlng ) ;
line . setTooltipContent (
` ${ v . file_name } · ${ statusLabel ( mapStatus ( v ) ) } ${ ch != null ? ` · ${ ( ch / 1000 ) . toFixed ( 4 ) } ` : "" } ` ,
) ;
} );
}
line . on ( "click" , ( ) = > select ( v ) ) ;
} ;
if ( sel && colorBy === "speed" && v . track ! . length > 3 ) {
// Selected video in speed mode: per-chunk gradient (Strava-style).
const t = v . track ! ;
const chunk = Math . max ( 2 , Math . ceil ( t . length / 24 ) ) ;
for ( let i = 0 ; i < t . length - 1 ; i += chunk ) {
const part = t . slice ( i , Math . min ( i + chunk + 1 , t . length ) ) ;
const dt = Math . max ( part [ part . length - 1 ] [ 2 ] - part [ 0 ] [ 2 ] , 0.5 ) ;
let d = 0 ;
for ( let k = 1 ; k < part . length ; k ++ ) {
const mLat = 110574 , mLon = 111320 * Math . cos ( ( part [ k ] [ 0 ] * Math . PI ) / 180 ) ;
d += Math . hypot ( ( part [ k ] [ 0 ] - part [ k - 1 ] [ 0 ] ) * mLat , ( part [ k ] [ 1 ] - part [ k - 1 ] [ 1 ] ) * mLon ) ;
}
const seg = L . polyline ( latlngs ( part ) , { color : speedColor ( d / dt ) , weight : 6 , opacity : 1 } ) ;
attachHandlers ( seg ) ;
seg . addTo ( layer ) ;
}
} else {
const line = L . polyline ( latlngs ( v . track ! ) , {
color ,
weight : sel ? 6 : 3 ,
opacity : sel ? 1 : 0.8 ,
dashArray : v.ignored ? "6 6" : undefined ,
} ) ;
attachHandlers ( line ) ;
line . addTo ( layer ) ;
if ( sel ) line . bringToFront ( ) ;
}
// Selected video: travel-direction arrows + green start / red end dots.
if ( sel ) {
const t = v . track ! ;
L . circleMarker ( [ t [ 0 ] [ 0 ] , t [ 0 ] [ 1 ] ] , { radius : 6 , color : "#0a1020" , weight : 2 , fillColor : "#5ccf75" , fillOpacity : 1 } )
. bindTooltip ( ` start · ${ t [ 0 ] [ 0 ] . toFixed ( 6 ) } , ${ t [ 0 ] [ 1 ] . toFixed ( 6 ) } ` )
. addTo ( layer ) ;
const last = t [ t . length - 1 ] ;
L . circleMarker ( [ last [ 0 ] , last [ 1 ] ] , { radius : 6 , color : "#0a1020" , weight : 2 , fillColor : "#ff5252" , fillOpacity : 1 } )
. bindTooltip ( ` end · ${ last [ 0 ] . toFixed ( 6 ) } , ${ last [ 1 ] . toFixed ( 6 ) } ` )
. addTo ( layer ) ;
const n = Math . min ( 7 , Math . floor ( t . length / 2 ) ) ;
for ( let k = 1 ; k <= n ; k ++ ) {
const i = Math . min ( Math . floor ( ( t . length - 2 ) * ( k / ( n + 1 ) ) ) , t . length - 2 ) ;
const rot = bearingDeg ( t [ i ] , t [ i + 1 ] ) ;
L . marker ( [ t [ i ] [ 0 ] , t [ i ] [ 1 ] ] , {
interactive : false ,
icon : L.divIcon ( {
className : "dir-arrow-wrap" ,
html : ` <div class="dir-arrow" style="transform:rotate( ${ rot } deg)">▲</div> ` ,
iconSize : [ 16 , 16 ] ,
iconAnchor : [ 8 , 8 ] ,
} ) ,
} ) . addTo ( layer ) ;
}
}
line . on ( "click" , ( ) = > select ( v ) ) ;
line . addTo ( layer ) ;
if ( sel ) line . bringToFront ( ) ;
}
if ( ! fitted . current && visible . length > 0 ) {
const all = visible . flatMap ( ( v ) = > latlngs ( v . track ! ) ) ;
map . fitBounds ( L . latLngBounds ( all ) , { padding : [ 30 , 30 ] } ) ;
fitted . current = true ;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ visible , colorBy , selectedId , correction , select ] ) ;
// ---- draw coverage findings (gaps amber, overlaps purple halo) ----
useEffect ( ( ) = > {
const layer = coverageLayer . current ;
if ( ! layer ) return ;
layer . clearLayers ( ) ;
if ( ! showCoverage ) return ;
for ( const c of coverage ) {
if ( ! c . pos ) continue ;
if ( roadFilter !== null && c . a . road_type !== roadFilter ) continue ;
const label =
c . kind === "gap"
? ` gap ${ c . size_m < 1000 ? ` ${ c . size_m . toFixed ( 0 ) } m ` : ` ${ ( c . size_m / 1000 ) . toFixed ( 2 ) } km ` } : ${ c . a . file_name } → ${ c . b . file_name } `
: ` overlap ~ ${ c . size_m < 1000 ? ` ${ c . size_m . toFixed ( 0 ) } m ` : ` ${ ( c . size_m / 1000 ) . toFixed ( 2 ) } km ` } : ${ c . a . file_name } ↔ ${ c . b . file_name } ` ;
L . circleMarker ( c . pos , {
radius : c.kind === "gap" ? 10 : 13 ,
color : c.kind === "gap" ? "#f5a623" : "#b48cff" ,
weight : c.kind === "gap" ? 3 : 7 ,
opacity : c.kind === "gap" ? 0.95 : 0.5 ,
fillOpacity : 0.12 ,
dashArray : c.kind === "gap" ? "4 4" : undefined ,
} )
. bindTooltip ( label )
. addTo ( layer ) ;
}
} , [ coverage , showCoverage , roadFilter ] ) ;
// ---- draw calibration points ----
useEffect ( ( ) = > {
const layer = pointLayer . current ;
@@ -360,7 +554,7 @@ export function MapView({
fillOpacity : 0.9 ,
} )
. bindTooltip (
` chainage ${ ( p . chainage_m / 1000 ) . toFixed ( 3 ) } km ${ p . road_type ? ` · ${ p . road_type } ` : "" } ${ p . note ? ` · ${ p . note } ` : "" } ` ,
` chainage ${ ( p . chainage_m / 1000 ) . toFixed ( 4 ) } ${ p . road_type ? ` · ${ p . road_type } ` : "" } ${ p . note ? ` · ${ p . note } ` : "" } ` ,
)
. addTo ( layer ) ;
}
@@ -459,7 +653,7 @@ export function MapView({
< span className = "mono" > { mt . v . file_name } < / span >
{ mt . ch != null ? (
// Same chainage can exist on LHS, RHS AND a ramp — one row per group.
< span className = "dim" > · < b > { mt . v . road_type || "no group" } < / b > · contains { ( mt . ch / 1000 ) . toFixed ( 3 ) } km < / span >
< span className = "dim" > · < b > { mt . v . road_type || "no group" } < / b > · contains { ( mt . ch / 1000 ) . toFixed ( 4 ) } < / span >
) : mt . away != null ? (
< span className = "dim" > · { mt . away . toFixed ( 0 ) } m away { mt . v . road_type ? ` · ${ mt . v . road_type } ` : "" } < / span >
) : mt . v . track ? (
@@ -476,12 +670,37 @@ export function MapView({
< / div >
< div className = "map-colorby" >
< button className = { colorBy === "video" ? "active" : "" } onClick = { ( ) = > setColorBy ( "video" ) } >
Color by v ideo
V ideo
< / button >
< button className = { colorBy === "status" ? "active" : "" } onClick = { ( ) = > setColorBy ( "status" ) } >
Color by s tatus
S tatus
< / button >
< button className = { colorBy === "speed" ? "active" : "" } onClick = { ( ) = > setColorBy ( "speed" ) }
title = "Color each video by its average speed; the selected video shows a per-stretch speed gradient" >
Speed
< / button >
< / div >
< div className = "map-colorby" >
< button className = { basemap === "map" ? "active" : "" } onClick = { ( ) = > setBasemap ( "map" ) } > Map < / button >
< button className = { basemap === "sat" ? "active" : "" } onClick = { ( ) = > setBasemap ( "sat" ) }
title = "Esri satellite imagery — zoom all the way in to inspect a junction" > Satellite < / button >
< / div >
{ roadTypesPresent . length > 1 && (
< div className = "map-chips" >
< button className = { ` map-chip ${ roadFilter === null ? "" : " off" } ` } onClick = { ( ) = > setRoadFilter ( null ) } >
all roads
< / button >
{ roadTypesPresent . map ( ( rt ) = > (
< button
key = { rt || "(none)" }
className = { ` map-chip ${ roadFilter === rt ? "" : " off" } ` }
onClick = { ( ) = > setRoadFilter ( roadFilter === rt ? null : rt ) }
>
{ rt || "(no road)" }
< / button >
) ) }
< / div >
) }
< a className = "btn" href = { tracksKmlUrl ( projectId ) } download
title = "All tracks (status colors) + calibration points — opens in Google Earth" >
⬇ KML
@@ -501,6 +720,16 @@ export function MapView({
) ) }
< / div >
) }
{ colorBy === "speed" && (
< div className = "map-chips" title = "km/h" >
{ SPEED_LEGEND . map ( ( [ lbl , col ] ) = > (
< span key = { lbl } className = "map-chip" style = { { borderColor : col , color : col , cursor : "default" } } >
{ lbl }
< / span >
) ) }
< span className = "dim" > km / h < / span >
< / div >
) }
< / div >
{ error && < div className = "error" > ⚠ { error } < / div > }
@@ -524,6 +753,25 @@ export function MapView({
< / span >
< span className = "dim" > Road type < / span >
< span > { selected . road_type || "—" } < / span >
{ selected . track && selected . track . length >= 2 && (
< >
< span className = "dim" > First fix < / span >
< span className = "mono" > { selected . track [ 0 ] [ 0 ] . toFixed ( 6 ) } , { selected . track [ 0 ] [ 1 ] . toFixed ( 6 ) } < / span >
< span className = "dim" > Last fix < / span >
< span className = "mono" >
{ selected . track [ selected . track . length - 1 ] [ 0 ] . toFixed ( 6 ) } , { selected . track [ selected . track . length - 1 ] [ 1 ] . toFixed ( 6 ) }
< / span >
< / >
) }
{ selected . speed_avg_mps != null && (
< >
< span className = "dim" > Speed < / span >
< span >
avg { ( selected . speed_avg_mps * 3.6 ) . toFixed ( 0 ) } km / h
{ selected . speed_max_mps != null && < > · max { ( selected . speed_max_mps * 3.6 ) . toFixed ( 0 ) } km / h < / > }
< / span >
< / >
) }
< span className = "dim" > Chainage < / span >
< span title = "client-calibrated" > { fmtChainageRange ( selected . chainage_start_m , selected . chainage_end_m ) } < / span >
< span className = "dim" > SR chainage < / span >
@@ -643,14 +891,50 @@ export function MapView({
< / div >
) }
{ /* Requirement: videos without metadata must be listed visibly . */ }
{ /* Coverage: holes and double-coverage between chainage-adjacent videos . */ }
{ coverage . length > 0 && (
< div className = "map-nometa" >
< div className = "map-nometa-head" >
Coverage
{ " " } < b > { coverage . filter ( ( c ) = > c . kind === "gap" ) . length } < / b > gap ( s )
{ " · " } < b > { coverage . filter ( ( c ) = > c . kind === "overlap" ) . length } < / b > overlap ( s )
< button className = "btn mini" style = { { marginLeft : 8 } }
onClick = { ( ) = > setShowCoverage ( ( s ) = > ! s ) }
title = "Show/hide the gap + overlap markers on the map" >
{ showCoverage ? "hide" : "show" }
< / button >
< / div >
< div className = "map-nometa-list" >
{ coverage . slice ( 0 , 40 ) . map ( ( c , i ) = > (
< div key = { i } className = "map-nometa-row" >
< button
className = "map-nometa-name mono"
title = { ` ${ c . a . file_name } ${ c . kind === "gap" ? "→" : "↔" } ${ c . b . file_name } ` }
onClick = { ( ) = > {
if ( c . pos && mapRef . current ) mapRef . current . setView ( c . pos , 15 ) ;
} }
>
{ c . a . file_name } { c . kind === "gap" ? "→" : "↔" } { c . b . file_name }
< / button >
< span className = { c . kind === "gap" ? "err-tag" : "dim" } style = { c . kind === "overlap" ? { color : "#b48cff" } : undefined } >
{ c . kind } { c . size_m < 1000 ? ` ${ c . size_m . toFixed ( 0 ) } m ` : ` ${ ( c . size_m / 1000 ) . toFixed ( 2 ) } km ` }
< / span >
< / div >
) ) }
{ coverage . length > 40 && < div className = "dim" > … and { coverage . length - 40 } more < / div > }
< / div >
< / div >
) }
{ /* Data quality: no metadata / corrupted / idle / long dropouts — all
the videos that need a human look, in one place. */ }
< div className = "map-nometa" >
< div className = "map-nometa-head" >
Without GPS metadata < b > { noMeta . length } < / b >
Data quality < b > { noMeta . length + idle . length + dropouts . length } < / b >
{ pendingScan . length > 0 && < span className = "dim" > · { pendingScan . length } awaiting scan < / span > }
{ isAdmin && (
< button className = "btn mini" disabled = { busy } style = { { marginLeft : 8 } }
title = "Queue a fresh scan of EVERY video (background, one at a time). Needed once to enable start-stitching on videos scanned before the upgrade. "
title = "Queue a fresh scan of EVERY video (background, one at a time)."
onClick = { ( ) = > {
if ( window . confirm ( "Re-scan GPS metadata for every video in this project? The sweep works through them in the background." ) )
void act ( ( ) = > api . gpsRescanAll ( projectId ) ) ;
@@ -666,9 +950,9 @@ export function MapView({
{ v . file_name }
< / button >
{ v . gps_status === "error" ? (
< span className = "err-tag" title = { v . gps_error ? ? "scan error" } > error < / span >
< span className = "err-tag" title = { v . gps_error ? ? "scan error" } > corrupt / unreadable < / span >
) : (
< span className = "dim" > none < / span >
< span className = "dim" > no GPS < / span >
) }
{ isAdmin && (
< button className = "btn mini" disabled = { busy } onClick = { ( ) = > rescan ( v ) } title = "Queue a fresh scan" >
@@ -677,7 +961,29 @@ export function MapView({
) }
< / div >
) ) }
{ noMeta . length === 0 && < div className = "dim" > every scanned video has GPS ✓ < / div > }
{ idle . map ( ( v ) = > (
< div key = { v . id } className = "map-nometa-row" >
< button className = "map-nometa-name mono" onClick = { ( ) = > select ( v ) } title = { v . rel_path } >
{ v . file_name }
< / button >
< span className = "idle-tag" title = { ` vehicle moved only ~ ${ ( v . gps_len_m ? ? 0 ) . toFixed ( 0 ) } m — parked/idling footage ` } >
idle { ( v . gps_len_m ? ? 0 ) . toFixed ( 0 ) } m
< / span >
< / div >
) ) }
{ dropouts . map ( ( v ) = > (
< div key = { v . id } className = "map-nometa-row" >
< button className = "map-nometa-name mono" onClick = { ( ) = > select ( v ) } title = { v . rel_path } >
{ v . file_name }
< / button >
< span className = "idle-tag" title = "longest stretch without a GPS fix — positions there are interpolated" >
dropout { ( v . gps_max_gap_s ? ? 0 ) . toFixed ( 0 ) } s
< / span >
< / div >
) ) }
{ noMeta . length + idle . length + dropouts . length === 0 && (
< div className = "dim" > every scanned video has healthy GPS ✓ < / div >
) }
< / div >
< / div >
< / div >