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
|
||||
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.
|
||||
`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
|
||||
**frame-exact** `lat`/`lon`/`chainage_m`/`sr_chainage_m` (range annotations also
|
||||
`end_*`): frame 15 of a 30 fps video is t = 0.5 s — halfway between two 1 Hz GPS
|
||||
samples — so it gets the halfway position/chainage, not a second/video boundary.
|
||||
**frame-exact** `lat`/`lon`/`chainage`/`sr_chainage` (range annotations also `end_*`):
|
||||
frame 15 of a 30 fps video is t = 0.5 s — halfway between two 1 Hz GPS samples — so it
|
||||
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
|
||||
`chainage_calibrations` so this works at any time after a recompute.
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ export function ChainagePanel({
|
||||
setKmlSkipped(skipped);
|
||||
setKmlRows(rows.map((r) => {
|
||||
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) {
|
||||
setError(String(e));
|
||||
@@ -272,7 +272,7 @@ export function ChainagePanel({
|
||||
setKmlUnit(u);
|
||||
setKmlRows((rows) => rows?.map((r) => {
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,13 +433,12 @@ 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,
|
||||
});
|
||||
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) {
|
||||
@@ -332,21 +446,101 @@ export function MapView({
|
||||
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` : ""}`,
|
||||
`${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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 video
|
||||
Video
|
||||
</button>
|
||||
<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>
|
||||
</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">
|
||||
Without GPS metadata <b>{noMeta.length}</b>
|
||||
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">
|
||||
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>
|
||||
|
||||
@@ -127,6 +127,11 @@ export interface MapVideo {
|
||||
gps_corrected_note: string | null;
|
||||
gps_sample_count: number | null; // raw fixes found by the scan
|
||||
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 {
|
||||
@@ -441,17 +446,18 @@ export function statusLabel(s: string): string {
|
||||
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 {
|
||||
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. */
|
||||
export function fmtChainageRange(a: number | null | undefined, b: number | null | undefined): string {
|
||||
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 +Δ, …
|
||||
|
||||
@@ -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; }
|
||||
.map-nometa-name:hover { color: var(--info); }
|
||||
.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-container { font: inherit; }
|
||||
.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)
|
||||
}
|
||||
|
||||
fn round3(x: f64) -> f64 {
|
||||
(x * 1000.0).round() / 1000.0
|
||||
/// Meters → km with 4 decimals (the user's chainage convention: 22.4456).
|
||||
fn km4(m: f64) -> f64 {
|
||||
(m * 10.0).round() / 10000.0
|
||||
}
|
||||
fn round6(x: f64) -> f64 {
|
||||
(x * 1e6).round() / 1e6
|
||||
}
|
||||
|
||||
/// Stamp every annotation in an export doc with its frame-exact geo: `lat`, `lon`,
|
||||
/// `sr_chainage_m` (measured) and `chainage_m` (client-calibrated) — range
|
||||
/// annotations additionally get `end_*` values at their end frame. Server-
|
||||
/// authoritative: applied on claim, re-applied on push and in both exports, so the
|
||||
/// values always reflect the CURRENT calibration.
|
||||
/// `sr_chainage` (measured) and `chainage` (client-calibrated), both in **km with
|
||||
/// 4 decimals** — range annotations additionally get `end_*` values at their end
|
||||
/// frame. Server-authoritative: applied on claim, re-applied on push and in both
|
||||
/// exports, so the values always reflect the CURRENT calibration.
|
||||
pub fn stamp_annotation_geo(doc: &mut Value, geo: &VideoGeo) {
|
||||
let Some(obj) = doc.as_object_mut() else { return };
|
||||
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);
|
||||
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}sr_chainage_m"), serde_json::json!(g.sr_m.map(round3)));
|
||||
m.insert(format!("{prefix}chainage_m"), serde_json::json!(g.chainage_m.map(round3)));
|
||||
m.insert(format!("{prefix}sr_chainage"), serde_json::json!(g.sr_m.map(km4)));
|
||||
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()) {
|
||||
for a in arr {
|
||||
@@ -1035,10 +1039,14 @@ mod tests {
|
||||
});
|
||||
stamp_annotation_geo(&mut doc, &geo);
|
||||
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.get("chainage_m").is_none(), "old meter key must be gone");
|
||||
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]
|
||||
|
||||
@@ -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).
|
||||
pub fn downsample(track: Vec<Pt>, max: usize) -> Vec<Pt> {
|
||||
if track.len() <= max || max < 2 {
|
||||
@@ -851,6 +867,21 @@ mod tests {
|
||||
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]
|
||||
fn stitch_planning_rules() {
|
||||
// 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(gps_track_corrected, gps_track) AS track,
|
||||
(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"#
|
||||
);
|
||||
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)?;
|
||||
// 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.
|
||||
// Speed stats are computed BEFORE downsampling (max speed needs 1 Hz hops).
|
||||
for r in rows.iter_mut() {
|
||||
if let Some(t) = r.track.take() {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
@@ -613,7 +618,7 @@ async fn export_tracks_kml(
|
||||
desc.push_str(&format!(" · road: {road_type}"));
|
||||
}
|
||||
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!(
|
||||
"<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)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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.
|
||||
/// Chainage convention (user-fixed): plain keys, **km with 4 decimals** (22.4456).
|
||||
pub fn stamp_road_meta(
|
||||
doc: &mut serde_json::Value,
|
||||
road_type: &str,
|
||||
@@ -651,10 +663,14 @@ pub fn stamp_road_meta(
|
||||
) {
|
||||
if let Some(obj) = doc.as_object_mut() {
|
||||
obj.insert("road_type".into(), json!(road_type));
|
||||
obj.insert("chainage_start_m".into(), json!(chainage.0));
|
||||
obj.insert("chainage_end_m".into(), json!(chainage.1));
|
||||
obj.insert("sr_chainage_start_m".into(), json!(chainage.2));
|
||||
obj.insert("sr_chainage_end_m".into(), json!(chainage.3));
|
||||
obj.insert("chainage_start".into(), json!(km4(chainage.0)));
|
||||
obj.insert("chainage_end".into(), json!(km4(chainage.1)));
|
||||
obj.insert("sr_chainage_start".into(), json!(km4(chainage.2)));
|
||||
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.
|
||||
pub gps_sample_count: Option<i32>,
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user