map updates
This commit is contained in:
11
README.md
11
README.md
@@ -171,11 +171,14 @@ pass back. All Phase 3 routes require `Authorization: Bearer <token>`.
|
|||||||
The server image bundles exiftool; a serial background sweep extracts each video's GPS
|
The server image bundles exiftool; a serial background sweep extracts each video's GPS
|
||||||
track (`gps_status`: `pending → ok | none | error`). The raw track is immutable —
|
track (`gps_status`: `pending → ok | none | error`). The raw track is immutable —
|
||||||
a kept "Viterbi" correction lives beside it and is preferred by the map + chainage.
|
a kept "Viterbi" correction lives beside it and is preferred by the map + chainage.
|
||||||
`road_type` + the four chainage values are injected into every claim/export doc and
|
`road_type` + the four chainage values (`chainage_start`, `chainage_end`,
|
||||||
|
`sr_chainage_start`, `sr_chainage_end`) are injected into every claim/export doc and
|
||||||
re-stamped on push (server-authoritative). **Every annotation** additionally gets its
|
re-stamped on push (server-authoritative). **Every annotation** additionally gets its
|
||||||
**frame-exact** `lat`/`lon`/`chainage_m`/`sr_chainage_m` (range annotations also
|
**frame-exact** `lat`/`lon`/`chainage`/`sr_chainage` (range annotations also `end_*`):
|
||||||
`end_*`): frame 15 of a 30 fps video is t = 0.5 s — halfway between two 1 Hz GPS
|
frame 15 of a 30 fps video is t = 0.5 s — halfway between two 1 Hz GPS samples — so it
|
||||||
samples — so it gets the halfway position/chainage, not a second/video boundary.
|
gets the halfway position/chainage, not a second/video boundary.
|
||||||
|
**Chainage unit convention (everywhere — JSON + UI): kilometres with 4 decimals**,
|
||||||
|
e.g. `22.4456` (`chainage` = client-calibrated, `sr_chainage` = GPS-measured).
|
||||||
Distances are WGS84-ellipsoidal (no spherical bias); calibration anchors persist in
|
Distances are WGS84-ellipsoidal (no spherical bias); calibration anchors persist in
|
||||||
`chainage_calibrations` so this works at any time after a recompute.
|
`chainage_calibrations` so this works at any time after a recompute.
|
||||||
|
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ export function ChainagePanel({
|
|||||||
setKmlSkipped(skipped);
|
setKmlSkipped(skipped);
|
||||||
setKmlRows(rows.map((r) => {
|
setKmlRows(rows.map((r) => {
|
||||||
const m = parseChainageText(r.name, kmlUnit);
|
const m = parseChainageText(r.name, kmlUnit);
|
||||||
return { ...r, km: m != null ? (m / 1000).toFixed(3) : "", include: m != null };
|
return { ...r, km: m != null ? (m / 1000).toFixed(4) : "", include: m != null };
|
||||||
}));
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
@@ -272,7 +272,7 @@ export function ChainagePanel({
|
|||||||
setKmlUnit(u);
|
setKmlUnit(u);
|
||||||
setKmlRows((rows) => rows?.map((r) => {
|
setKmlRows((rows) => rows?.map((r) => {
|
||||||
const m = parseChainageText(r.name, u);
|
const m = parseChainageText(r.name, u);
|
||||||
return { ...r, km: m != null ? (m / 1000).toFixed(3) : "", include: m != null };
|
return { ...r, km: m != null ? (m / 1000).toFixed(4) : "", include: m != null };
|
||||||
}) ?? null);
|
}) ?? null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
/** "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 {
|
function parseChainageQuery(q: string): number | null {
|
||||||
const t = q.trim();
|
const t = q.trim();
|
||||||
@@ -135,7 +161,10 @@ export function MapView({
|
|||||||
}) {
|
}) {
|
||||||
const [videos, setVideos] = useState<MapVideo[]>([]);
|
const [videos, setVideos] = useState<MapVideo[]>([]);
|
||||||
const [points, setPoints] = useState<ChainagePoint[]>([]);
|
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 [hidden, setHidden] = useState<Set<string>>(new Set());
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
@@ -151,6 +180,8 @@ export function MapView({
|
|||||||
const pointLayer = useRef<L.LayerGroup | null>(null);
|
const pointLayer = useRef<L.LayerGroup | null>(null);
|
||||||
const correctionLayer = useRef<L.LayerGroup | null>(null);
|
const correctionLayer = useRef<L.LayerGroup | null>(null);
|
||||||
const searchLayer = 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);
|
const fitted = useRef(false);
|
||||||
// Per-video cumulative track distances, computed lazily for hover/search readouts.
|
// Per-video cumulative track distances, computed lazily for hover/search readouts.
|
||||||
const cumCache = useRef(new Map<number, number[]>());
|
const cumCache = useRef(new Map<number, number[]>());
|
||||||
@@ -196,14 +227,23 @@ export function MapView({
|
|||||||
// ---- Leaflet init (once) ----
|
// ---- Leaflet init (once) ----
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapDiv.current || mapRef.current) return;
|
if (!mapDiv.current || mapRef.current) return;
|
||||||
const map = L.map(mapDiv.current, { center: [20, 45], zoom: 5 });
|
const map = L.map(mapDiv.current, { center: [20, 45], zoom: 5, maxZoom: 22 });
|
||||||
// OSM raster tiles (internet). The basemap is an optional layer — tracks render
|
// Two basemaps (both optional — tracks render fine when tiles can't load):
|
||||||
// fine even when tiles can't load (offline VM).
|
// OSM streets and Esri satellite. maxNativeZoom 19 + maxZoom 22 over-zooms the
|
||||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
// deepest tiles so you can inspect a single junction ("deep zoom").
|
||||||
maxZoom: 19,
|
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',
|
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);
|
trackLayer.current = L.layerGroup().addTo(map);
|
||||||
|
coverageLayer.current = L.layerGroup().addTo(map);
|
||||||
pointLayer.current = L.layerGroup().addTo(map);
|
pointLayer.current = L.layerGroup().addTo(map);
|
||||||
correctionLayer.current = L.layerGroup().addTo(map);
|
correctionLayer.current = L.layerGroup().addTo(map);
|
||||||
searchLayer.current = L.layerGroup().addTo(map);
|
searchLayer.current = L.layerGroup().addTo(map);
|
||||||
@@ -212,14 +252,36 @@ export function MapView({
|
|||||||
return () => {
|
return () => {
|
||||||
map.remove();
|
map.remove();
|
||||||
mapRef.current = null;
|
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]);
|
useEffect(() => cumCache.current.clear(), [videos]);
|
||||||
|
|
||||||
const withTrack = useMemo(() => videos.filter((v) => v.track && v.track.length >= 2), [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 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]);
|
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 statusCounts = useMemo(() => {
|
||||||
const c: Record<string, number> = {};
|
const c: Record<string, number> = {};
|
||||||
@@ -227,11 +289,60 @@ export function MapView({
|
|||||||
return c;
|
return c;
|
||||||
}, [withTrack]);
|
}, [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(
|
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
|
// 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
|
// 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).
|
// coordinates ("21.1055, 78.1857" — nearby tracks + a bare go-to-location entry).
|
||||||
@@ -248,9 +359,16 @@ export function MapView({
|
|||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
return [{ v: null, ll } as SearchMatch, ...near];
|
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);
|
const m = parseChainageQuery(q);
|
||||||
if (m != null) {
|
if (m != null) {
|
||||||
return videos
|
const byCh: SearchMatch[] = videos
|
||||||
.filter((v) => {
|
.filter((v) => {
|
||||||
if (v.chainage_start_m == null || v.chainage_end_m == null) return false;
|
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);
|
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))
|
.sort((a, b) => a.road_type.localeCompare(b.road_type) || a.file_name.localeCompare(b.file_name))
|
||||||
.slice(0, 12)
|
.slice(0, 12)
|
||||||
.map((v): SearchMatch => ({ v, ch: m }));
|
.map((v): SearchMatch => ({ v, ch: m }));
|
||||||
|
return [...byName, ...byCh].slice(0, 14);
|
||||||
}
|
}
|
||||||
const lq = q.toLowerCase();
|
return byName;
|
||||||
return videos
|
|
||||||
.filter((v) => v.file_name.toLowerCase().includes(lq) || v.rel_path.toLowerCase().includes(lq))
|
|
||||||
.slice(0, 12)
|
|
||||||
.map((v): SearchMatch => ({ v }));
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [videos, search]);
|
}, [videos, search]);
|
||||||
|
|
||||||
@@ -291,7 +406,7 @@ export function MapView({
|
|||||||
const pt = cum ? pointAtChainage(v, cum, m) : null;
|
const pt = cum ? pointAtChainage(v, cum, m) : null;
|
||||||
if (pt && mapRef.current && searchLayer.current) {
|
if (pt && mapRef.current && searchLayer.current) {
|
||||||
L.circleMarker(pt, { radius: 9, color: "#ff3b3b", weight: 3, fillOpacity: 0.15 })
|
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);
|
.addTo(searchLayer.current);
|
||||||
mapRef.current.setView(pt, 16);
|
mapRef.current.setView(pt, 16);
|
||||||
}
|
}
|
||||||
@@ -318,35 +433,114 @@ export function MapView({
|
|||||||
for (const v of visible) {
|
for (const v of visible) {
|
||||||
if (correction && correction.videoId === v.id) continue; // overlay owns this one
|
if (correction && correction.videoId === v.id) continue; // overlay owns this one
|
||||||
const sel = v.id === selectedId;
|
const sel = v.id === selectedId;
|
||||||
const color = colorBy === "video" ? videoColor(v.id) : STATUS_COLORS[mapStatus(v)] ?? "#999";
|
const color =
|
||||||
const line = L.polyline(latlngs(v.track!), {
|
colorBy === "video" ? videoColor(v.id)
|
||||||
color,
|
: colorBy === "speed" ? (v.speed_avg_mps != null ? speedColor(v.speed_avg_mps) : "#8f95ae")
|
||||||
weight: sel ? 6 : 3,
|
: STATUS_COLORS[mapStatus(v)] ?? "#999";
|
||||||
opacity: sel ? 1 : 0.8,
|
|
||||||
dashArray: v.ignored ? "6 6" : undefined,
|
const attachHandlers = (line: L.Polyline) => {
|
||||||
});
|
line.bindTooltip(`${v.file_name} · ${statusLabel(mapStatus(v))}`, { sticky: true });
|
||||||
line.bindTooltip(`${v.file_name} · ${statusLabel(mapStatus(v))}`, { sticky: true });
|
const cum = cumFor(v);
|
||||||
const cum = cumFor(v);
|
if (cum && v.chainage_start_m != null) {
|
||||||
if (cum && v.chainage_start_m != null) {
|
// Live chainage readout under the cursor.
|
||||||
// Live chainage readout under the cursor.
|
line.on("mousemove", (e: L.LeafletMouseEvent) => {
|
||||||
line.on("mousemove", (e: L.LeafletMouseEvent) => {
|
const ch = chainageAtCursor(v, cum, e.latlng);
|
||||||
const ch = chainageAtCursor(v, cum, e.latlng);
|
line.setTooltipContent(
|
||||||
line.setTooltipContent(
|
`${v.file_name} · ${statusLabel(mapStatus(v))}${ch != null ? ` · ${(ch / 1000).toFixed(4)}` : ""}`,
|
||||||
`${v.file_name} · ${statusLabel(mapStatus(v))}${ch != null ? ` · ${(ch / 1000).toFixed(3)} km` : ""}`,
|
);
|
||||||
);
|
});
|
||||||
|
}
|
||||||
|
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) {
|
if (!fitted.current && visible.length > 0) {
|
||||||
const all = visible.flatMap((v) => latlngs(v.track!));
|
const all = visible.flatMap((v) => latlngs(v.track!));
|
||||||
map.fitBounds(L.latLngBounds(all), { padding: [30, 30] });
|
map.fitBounds(L.latLngBounds(all), { padding: [30, 30] });
|
||||||
fitted.current = true;
|
fitted.current = true;
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [visible, colorBy, selectedId, correction, select]);
|
}, [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 ----
|
// ---- draw calibration points ----
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const layer = pointLayer.current;
|
const layer = pointLayer.current;
|
||||||
@@ -360,7 +554,7 @@ export function MapView({
|
|||||||
fillOpacity: 0.9,
|
fillOpacity: 0.9,
|
||||||
})
|
})
|
||||||
.bindTooltip(
|
.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);
|
.addTo(layer);
|
||||||
}
|
}
|
||||||
@@ -459,7 +653,7 @@ export function MapView({
|
|||||||
<span className="mono">{mt.v.file_name}</span>
|
<span className="mono">{mt.v.file_name}</span>
|
||||||
{mt.ch != null ? (
|
{mt.ch != null ? (
|
||||||
// Same chainage can exist on LHS, RHS AND a ramp — one row per group.
|
// 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 ? (
|
) : mt.away != null ? (
|
||||||
<span className="dim"> · {mt.away.toFixed(0)} m away{mt.v.road_type ? ` · ${mt.v.road_type}` : ""}</span>
|
<span className="dim"> · {mt.away.toFixed(0)} m away{mt.v.road_type ? ` · ${mt.v.road_type}` : ""}</span>
|
||||||
) : mt.v.track ? (
|
) : mt.v.track ? (
|
||||||
@@ -476,12 +670,37 @@ export function MapView({
|
|||||||
</div>
|
</div>
|
||||||
<div className="map-colorby">
|
<div className="map-colorby">
|
||||||
<button className={colorBy === "video" ? "active" : ""} onClick={() => setColorBy("video")}>
|
<button className={colorBy === "video" ? "active" : ""} onClick={() => setColorBy("video")}>
|
||||||
Color by video
|
Video
|
||||||
</button>
|
</button>
|
||||||
<button className={colorBy === "status" ? "active" : ""} onClick={() => setColorBy("status")}>
|
<button className={colorBy === "status" ? "active" : ""} onClick={() => setColorBy("status")}>
|
||||||
Color by status
|
Status
|
||||||
|
</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>
|
</button>
|
||||||
</div>
|
</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
|
<a className="btn" href={tracksKmlUrl(projectId)} download
|
||||||
title="All tracks (status colors) + calibration points — opens in Google Earth">
|
title="All tracks (status colors) + calibration points — opens in Google Earth">
|
||||||
⬇ KML
|
⬇ KML
|
||||||
@@ -501,6 +720,16 @@ export function MapView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{error && <div className="error">⚠ {error}</div>}
|
{error && <div className="error">⚠ {error}</div>}
|
||||||
@@ -524,6 +753,25 @@ export function MapView({
|
|||||||
</span>
|
</span>
|
||||||
<span className="dim">Road type</span>
|
<span className="dim">Road type</span>
|
||||||
<span>{selected.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 className="dim">Chainage</span>
|
||||||
<span title="client-calibrated">{fmtChainageRange(selected.chainage_start_m, selected.chainage_end_m)}</span>
|
<span title="client-calibrated">{fmtChainageRange(selected.chainage_start_m, selected.chainage_end_m)}</span>
|
||||||
<span className="dim">SR chainage</span>
|
<span className="dim">SR chainage</span>
|
||||||
@@ -643,14 +891,50 @@ export function MapView({
|
|||||||
</div>
|
</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">
|
||||||
<div className="map-nometa-head">
|
<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>}
|
{pendingScan.length > 0 && <span className="dim"> · {pendingScan.length} awaiting scan</span>}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button className="btn mini" disabled={busy} style={{ marginLeft: 8 }}
|
<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={() => {
|
onClick={() => {
|
||||||
if (window.confirm("Re-scan GPS metadata for every video in this project? The sweep works through them in the background."))
|
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));
|
void act(() => api.gpsRescanAll(projectId));
|
||||||
@@ -666,9 +950,9 @@ export function MapView({
|
|||||||
{v.file_name}
|
{v.file_name}
|
||||||
</button>
|
</button>
|
||||||
{v.gps_status === "error" ? (
|
{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 && (
|
{isAdmin && (
|
||||||
<button className="btn mini" disabled={busy} onClick={() => rescan(v)} title="Queue a fresh scan">
|
<button className="btn mini" disabled={busy} onClick={() => rescan(v)} title="Queue a fresh scan">
|
||||||
@@ -677,7 +961,29 @@ export function MapView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -127,6 +127,11 @@ export interface MapVideo {
|
|||||||
gps_corrected_note: string | null;
|
gps_corrected_note: string | null;
|
||||||
gps_sample_count: number | null; // raw fixes found by the scan
|
gps_sample_count: number | null; // raw fixes found by the scan
|
||||||
gps_max_gap_s: number | null; // biggest dropout between fixes
|
gps_max_gap_s: number | null; // biggest dropout between fixes
|
||||||
|
duration_s: number | null;
|
||||||
|
gps_start_epoch: number | null; // recording window (UTC epoch of first/last fix)
|
||||||
|
gps_end_epoch: number | null;
|
||||||
|
speed_avg_mps: number | null; // over the full-resolution track
|
||||||
|
speed_max_mps: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoadType {
|
export interface RoadType {
|
||||||
@@ -441,17 +446,18 @@ export function statusLabel(s: string): string {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Chainage in decimal km ("12.345 km"); em-dash when not computed yet. */
|
/** Chainage convention (user-fixed): km with FOUR decimals — "22.4456".
|
||||||
|
* Input is meters (internal unit); em-dash when not computed yet. */
|
||||||
export function fmtChainage(m: number | null | undefined): string {
|
export function fmtChainage(m: number | null | undefined): string {
|
||||||
if (m == null || isNaN(m)) return "—";
|
if (m == null || isNaN(m)) return "—";
|
||||||
return `${(m / 1000).toFixed(3)} km`;
|
return (m / 1000).toFixed(4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "12.400 → 13.750 km" for a start/end pair (direction preserved — a flipped video
|
/** "12.4000 → 13.7500" for a start/end pair (direction preserved — a flipped video
|
||||||
* legitimately runs high→low); em-dash when not computed. */
|
* legitimately runs high→low); em-dash when not computed. */
|
||||||
export function fmtChainageRange(a: number | null | undefined, b: number | null | undefined): string {
|
export function fmtChainageRange(a: number | null | undefined, b: number | null | undefined): string {
|
||||||
if (a == null || b == null) return "—";
|
if (a == null || b == null) return "—";
|
||||||
return `${(a / 1000).toFixed(3)} → ${(b / 1000).toFixed(3)} km`;
|
return `${(a / 1000).toFixed(4)} → ${(b / 1000).toFixed(4)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-pass annotation deltas from the imported baseline: verify +Δ, re-verify +Δ, …
|
/** Per-pass annotation deltas from the imported baseline: verify +Δ, re-verify +Δ, …
|
||||||
|
|||||||
@@ -298,6 +298,13 @@ tr.rank-top { background: rgba(245,200,56,0.08); }
|
|||||||
font-size: 11px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
font-size: 11px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||||
.map-nometa-name:hover { color: var(--info); }
|
.map-nometa-name:hover { color: var(--info); }
|
||||||
.btn.mini { padding: 1px 7px; font-size: 11px; }
|
.btn.mini { padding: 1px 7px; font-size: 11px; }
|
||||||
|
/* Travel-direction arrows along the selected track */
|
||||||
|
.dir-arrow-wrap { background: none; border: none; }
|
||||||
|
.dir-arrow { color: #ffffff; font-size: 13px; line-height: 16px; text-align: center;
|
||||||
|
text-shadow: 0 0 3px #000, 0 0 5px #000; pointer-events: none; }
|
||||||
|
/* Idle / dropout tags in the data-quality panel */
|
||||||
|
.idle-tag { color: var(--warn); font-size: 11px; white-space: nowrap; }
|
||||||
|
|
||||||
/* Leaflet dark-theme nudges */
|
/* Leaflet dark-theme nudges */
|
||||||
.leaflet-container { font: inherit; }
|
.leaflet-container { font: inherit; }
|
||||||
.leaflet-container a.leaflet-attribution-flag { display: none !important; }
|
.leaflet-container a.leaflet-attribution-flag { display: none !important; }
|
||||||
|
|||||||
@@ -390,18 +390,19 @@ pub async fn load_calibration(db: &sqlx::PgPool, project_id: i32, road_type: &st
|
|||||||
v.as_ref().and_then(Calib::from_value)
|
v.as_ref().and_then(Calib::from_value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn round3(x: f64) -> f64 {
|
/// Meters → km with 4 decimals (the user's chainage convention: 22.4456).
|
||||||
(x * 1000.0).round() / 1000.0
|
fn km4(m: f64) -> f64 {
|
||||||
|
(m * 10.0).round() / 10000.0
|
||||||
}
|
}
|
||||||
fn round6(x: f64) -> f64 {
|
fn round6(x: f64) -> f64 {
|
||||||
(x * 1e6).round() / 1e6
|
(x * 1e6).round() / 1e6
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stamp every annotation in an export doc with its frame-exact geo: `lat`, `lon`,
|
/// Stamp every annotation in an export doc with its frame-exact geo: `lat`, `lon`,
|
||||||
/// `sr_chainage_m` (measured) and `chainage_m` (client-calibrated) — range
|
/// `sr_chainage` (measured) and `chainage` (client-calibrated), both in **km with
|
||||||
/// annotations additionally get `end_*` values at their end frame. Server-
|
/// 4 decimals** — range annotations additionally get `end_*` values at their end
|
||||||
/// authoritative: applied on claim, re-applied on push and in both exports, so the
|
/// frame. Server-authoritative: applied on claim, re-applied on push and in both
|
||||||
/// values always reflect the CURRENT calibration.
|
/// exports, so the values always reflect the CURRENT calibration.
|
||||||
pub fn stamp_annotation_geo(doc: &mut Value, geo: &VideoGeo) {
|
pub fn stamp_annotation_geo(doc: &mut Value, geo: &VideoGeo) {
|
||||||
let Some(obj) = doc.as_object_mut() else { return };
|
let Some(obj) = doc.as_object_mut() else { return };
|
||||||
let stamp = |m: &mut serde_json::Map<String, Value>, frame_key: &str, prefix: &str| {
|
let stamp = |m: &mut serde_json::Map<String, Value>, frame_key: &str, prefix: &str| {
|
||||||
@@ -409,8 +410,11 @@ pub fn stamp_annotation_geo(doc: &mut Value, geo: &VideoGeo) {
|
|||||||
let g = geo.at_frame(frame);
|
let g = geo.at_frame(frame);
|
||||||
m.insert(format!("{prefix}lat"), serde_json::json!(round6(g.lat)));
|
m.insert(format!("{prefix}lat"), serde_json::json!(round6(g.lat)));
|
||||||
m.insert(format!("{prefix}lon"), serde_json::json!(round6(g.lon)));
|
m.insert(format!("{prefix}lon"), serde_json::json!(round6(g.lon)));
|
||||||
m.insert(format!("{prefix}sr_chainage_m"), serde_json::json!(g.sr_m.map(round3)));
|
m.insert(format!("{prefix}sr_chainage"), serde_json::json!(g.sr_m.map(km4)));
|
||||||
m.insert(format!("{prefix}chainage_m"), serde_json::json!(g.chainage_m.map(round3)));
|
m.insert(format!("{prefix}chainage"), serde_json::json!(g.chainage_m.map(km4)));
|
||||||
|
// Drop earlier meter-suffixed variants from previously-stamped docs.
|
||||||
|
m.remove(&format!("{prefix}sr_chainage_m"));
|
||||||
|
m.remove(&format!("{prefix}chainage_m"));
|
||||||
};
|
};
|
||||||
if let Some(arr) = obj.get_mut("fixed_annotations").and_then(|v| v.as_array_mut()) {
|
if let Some(arr) = obj.get_mut("fixed_annotations").and_then(|v| v.as_array_mut()) {
|
||||||
for a in arr {
|
for a in arr {
|
||||||
@@ -1035,10 +1039,14 @@ mod tests {
|
|||||||
});
|
});
|
||||||
stamp_annotation_geo(&mut doc, &geo);
|
stamp_annotation_geo(&mut doc, &geo);
|
||||||
let f = &doc["fixed_annotations"][0];
|
let f = &doc["fixed_annotations"][0];
|
||||||
assert!(f["chainage_m"].as_f64().unwrap() > 15.0);
|
// km, 4 decimals: frame 30 = t 1 s ≈ 22.7 m ≈ 0.0204 km after calibration.
|
||||||
|
let ch = f["chainage"].as_f64().unwrap();
|
||||||
|
assert!(ch > 0.015 && ch < 0.025, "{ch}");
|
||||||
|
assert_eq!(ch, (ch * 10000.0).round() / 10000.0, "not 4-decimal km");
|
||||||
assert!(f["lat"].as_f64().unwrap().abs() < 1e-6);
|
assert!(f["lat"].as_f64().unwrap().abs() < 1e-6);
|
||||||
|
assert!(f.get("chainage_m").is_none(), "old meter key must be gone");
|
||||||
let r = &doc["range_annotations"][0];
|
let r = &doc["range_annotations"][0];
|
||||||
assert!(r["chainage_m"].as_f64().unwrap() < r["end_chainage_m"].as_f64().unwrap());
|
assert!(r["chainage"].as_f64().unwrap() < r["end_chainage"].as_f64().unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -103,6 +103,22 @@ pub fn track_value(track: &[Pt]) -> Value {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Average + maximum speed over a track (m/s), from the full-resolution samples.
|
||||||
|
/// Max is measured over single hops (dt clamped ≥ 0.5 s so duplicate timestamps
|
||||||
|
/// can't fabricate teleports). None when the track has no elapsed time.
|
||||||
|
pub fn speed_stats(track: &[Pt]) -> (Option<f64>, Option<f64>) {
|
||||||
|
if track.len() < 2 {
|
||||||
|
return (None, None);
|
||||||
|
}
|
||||||
|
let elapsed = track.last().unwrap()[2] - track[0][2];
|
||||||
|
let avg = (elapsed > 0.0).then(|| polyline_len_m(track) / elapsed);
|
||||||
|
let max = track
|
||||||
|
.windows(2)
|
||||||
|
.map(|w| dist_m(w[0][0], w[0][1], w[1][0], w[1][1]) / (w[1][2] - w[0][2]).max(0.5))
|
||||||
|
.fold(None::<f64>, |acc, v| Some(acc.map_or(v, |a| a.max(v))));
|
||||||
|
(avg, max)
|
||||||
|
}
|
||||||
|
|
||||||
/// Cap a track at `max` points (uniform stride, always keeping the last point).
|
/// Cap a track at `max` points (uniform stride, always keeping the last point).
|
||||||
pub fn downsample(track: Vec<Pt>, max: usize) -> Vec<Pt> {
|
pub fn downsample(track: Vec<Pt>, max: usize) -> Vec<Pt> {
|
||||||
if track.len() <= max || max < 2 {
|
if track.len() <= max || max < 2 {
|
||||||
@@ -851,6 +867,21 @@ mod tests {
|
|||||||
assert_eq!(d.last(), t.last());
|
assert_eq!(d.last(), t.last());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn speed_stats_measures_avg_and_max() {
|
||||||
|
// ~11.13 m per second for 60 s → avg ≈ max ≈ 11.1 m/s (40 km/h).
|
||||||
|
let t = straight(60);
|
||||||
|
let (avg, max) = speed_stats(&t);
|
||||||
|
assert!((avg.unwrap() - 11.13).abs() < 0.1, "{avg:?}");
|
||||||
|
assert!((max.unwrap() - 11.13).abs() < 0.1, "{max:?}");
|
||||||
|
// One fast hop dominates max but barely moves avg.
|
||||||
|
let mut t2 = straight(60);
|
||||||
|
t2[30][1] += 0.0002; // extra ~22 m in that second
|
||||||
|
let (avg2, max2) = speed_stats(&t2);
|
||||||
|
assert!(max2.unwrap() > 25.0, "{max2:?}");
|
||||||
|
assert!(avg2.unwrap() < 13.0, "{avg2:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stitch_planning_rules() {
|
fn stitch_planning_rules() {
|
||||||
// Healthy 1 Hz rollover (1 s gap) → nothing missing.
|
// Healthy 1 Hz rollover (1 s gap) → nothing missing.
|
||||||
|
|||||||
@@ -434,7 +434,8 @@ async fn map_data(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPath<
|
|||||||
COALESCE((SELECT count(*) FROM video_remarks r WHERE r.video_id = videos.id), 0) AS remark_count,
|
COALESCE((SELECT count(*) FROM video_remarks r WHERE r.video_id = videos.id), 0) AS remark_count,
|
||||||
COALESCE(gps_track_corrected, gps_track) AS track,
|
COALESCE(gps_track_corrected, gps_track) AS track,
|
||||||
(gps_track_corrected IS NOT NULL) AS corrected,
|
(gps_track_corrected IS NOT NULL) AS corrected,
|
||||||
gps_corrected_by, gps_corrected_note, gps_sample_count, gps_max_gap_s
|
gps_corrected_by, gps_corrected_note, gps_sample_count, gps_max_gap_s,
|
||||||
|
duration_s, gps_start_epoch, gps_end_epoch
|
||||||
FROM videos WHERE project_id=$1 {FOLDER_FILTER} ORDER BY file_name"#
|
FROM videos WHERE project_id=$1 {FOLDER_FILTER} ORDER BY file_name"#
|
||||||
);
|
);
|
||||||
let mut rows = sqlx::query_as::<_, MapVideo>(&q)
|
let mut rows = sqlx::query_as::<_, MapVideo>(&q)
|
||||||
@@ -444,9 +445,13 @@ async fn map_data(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPath<
|
|||||||
.map_err(err)?;
|
.map_err(err)?;
|
||||||
// Downsample tracks for transport — hundreds of videos × 5k points would be tens
|
// Downsample tracks for transport — hundreds of videos × 5k points would be tens
|
||||||
// of MB; ~400 points per video keeps the payload in the low single-digit MB.
|
// of MB; ~400 points per video keeps the payload in the low single-digit MB.
|
||||||
|
// Speed stats are computed BEFORE downsampling (max speed needs 1 Hz hops).
|
||||||
for r in rows.iter_mut() {
|
for r in rows.iter_mut() {
|
||||||
if let Some(t) = r.track.take() {
|
if let Some(t) = r.track.take() {
|
||||||
let pts = gps::parse_track(&t);
|
let pts = gps::parse_track(&t);
|
||||||
|
let (avg, max) = gps::speed_stats(&pts);
|
||||||
|
r.speed_avg_mps = avg;
|
||||||
|
r.speed_max_mps = max;
|
||||||
r.track = Some(gps::track_value(&gps::downsample(pts, MAP_MAX_PTS)));
|
r.track = Some(gps::track_value(&gps::downsample(pts, MAP_MAX_PTS)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -613,7 +618,7 @@ async fn export_tracks_kml(
|
|||||||
desc.push_str(&format!(" · road: {road_type}"));
|
desc.push_str(&format!(" · road: {road_type}"));
|
||||||
}
|
}
|
||||||
if let (Some(a), Some(b)) = (ch_s, ch_e) {
|
if let (Some(a), Some(b)) = (ch_s, ch_e) {
|
||||||
desc.push_str(&format!(" · chainage {:.3} → {:.3} km", a / 1000.0, b / 1000.0));
|
desc.push_str(&format!(" · chainage {:.4} → {:.4}", a / 1000.0, b / 1000.0));
|
||||||
}
|
}
|
||||||
kml.push_str(&format!(
|
kml.push_str(&format!(
|
||||||
"<Placemark><name>{}</name><description>{}</description><styleUrl>#st-{}</styleUrl><LineString><tessellate>1</tessellate><coordinates>",
|
"<Placemark><name>{}</name><description>{}</description><styleUrl>#st-{}</styleUrl><LineString><tessellate>1</tessellate><coordinates>",
|
||||||
@@ -641,9 +646,16 @@ async fn export_tracks_kml(
|
|||||||
.map_err(err)
|
.map_err(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Meters → the wire/display convention: kilometers with 4 decimals (0.1 m).
|
||||||
|
/// 151779.98 m → 151.78 km; 22445.6 m → 22.4456 km.
|
||||||
|
pub fn km4(m: Option<f64>) -> Option<f64> {
|
||||||
|
m.map(|v| (v * 10.0).round() / 10000.0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Server-authoritative wire-format metadata: inject/overwrite `road_type` + chainage
|
/// Server-authoritative wire-format metadata: inject/overwrite `road_type` + chainage
|
||||||
/// keys at the top level of an export doc. Applied on claim (pull), push (store) and
|
/// keys at the top level of an export doc. Applied on claim (pull), push (store) and
|
||||||
/// both export endpoints, so a worker's old client can never drop them.
|
/// both export endpoints, so a worker's old client can never drop them.
|
||||||
|
/// Chainage convention (user-fixed): plain keys, **km with 4 decimals** (22.4456).
|
||||||
pub fn stamp_road_meta(
|
pub fn stamp_road_meta(
|
||||||
doc: &mut serde_json::Value,
|
doc: &mut serde_json::Value,
|
||||||
road_type: &str,
|
road_type: &str,
|
||||||
@@ -651,10 +663,14 @@ pub fn stamp_road_meta(
|
|||||||
) {
|
) {
|
||||||
if let Some(obj) = doc.as_object_mut() {
|
if let Some(obj) = doc.as_object_mut() {
|
||||||
obj.insert("road_type".into(), json!(road_type));
|
obj.insert("road_type".into(), json!(road_type));
|
||||||
obj.insert("chainage_start_m".into(), json!(chainage.0));
|
obj.insert("chainage_start".into(), json!(km4(chainage.0)));
|
||||||
obj.insert("chainage_end_m".into(), json!(chainage.1));
|
obj.insert("chainage_end".into(), json!(km4(chainage.1)));
|
||||||
obj.insert("sr_chainage_start_m".into(), json!(chainage.2));
|
obj.insert("sr_chainage_start".into(), json!(km4(chainage.2)));
|
||||||
obj.insert("sr_chainage_end_m".into(), json!(chainage.3));
|
obj.insert("sr_chainage_end".into(), json!(km4(chainage.3)));
|
||||||
|
// Drop earlier meter-suffixed variants so re-stamped docs don't carry both.
|
||||||
|
for k in ["chainage_start_m", "chainage_end_m", "sr_chainage_start_m", "sr_chainage_end_m"] {
|
||||||
|
obj.remove(k);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -214,6 +214,15 @@ pub struct MapVideo {
|
|||||||
// Data quality from the raw scan: fix count + biggest dropout between fixes.
|
// Data quality from the raw scan: fix count + biggest dropout between fixes.
|
||||||
pub gps_sample_count: Option<i32>,
|
pub gps_sample_count: Option<i32>,
|
||||||
pub gps_max_gap_s: Option<f64>,
|
pub gps_max_gap_s: Option<f64>,
|
||||||
|
pub duration_s: Option<f64>,
|
||||||
|
// Recording window (absolute UTC epoch of first/last fix) — temporal ordering.
|
||||||
|
pub gps_start_epoch: Option<f64>,
|
||||||
|
pub gps_end_epoch: Option<f64>,
|
||||||
|
// Speed over the full-resolution track (m/s) — the map's speed mode/stats.
|
||||||
|
#[sqlx(default)]
|
||||||
|
pub speed_avg_mps: Option<f64>,
|
||||||
|
#[sqlx(default)]
|
||||||
|
pub speed_max_mps: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/projects/:id/road_types` — a label + how many videos carry it.
|
/// `GET /api/projects/:id/road_types` — a label + how many videos carry it.
|
||||||
|
|||||||
Reference in New Issue
Block a user