bbox mode

This commit is contained in:
2026-05-19 18:31:44 +05:30
parent ac5deafbbf
commit 717d4986b6
6 changed files with 1648 additions and 102 deletions

View File

@@ -5,6 +5,7 @@ import { open, save } from "@tauri-apps/plugin-dialog";
const IMAGE_FOLDER_KEY = "kml_map_tool.image_folder";
const POPUP_SIZE_KEY = "kml_map_tool.popup_size";
const POPUP_ENABLED_KEY = "kml_map_tool.popup_enabled";
const BBOX_MODE_KEY = "kml_map_tool.bbox_mode";
const POPUP_ASPECT_KEY = "kml_map_tool.popup_aspect";
const QUALITY_OFF_TRACK_KEY = "kml_map_tool.quality_off_track_m";
const PERF_MODE_KEY = "kml_map_tool.perf_mode";
@@ -303,6 +304,13 @@ function AppShell({
const v = window.localStorage.getItem(POPUP_ENABLED_KEY);
return v === null ? true : v === "1";
});
const [bboxMode, setBboxMode] = useState<boolean>(() => {
if (typeof window === "undefined") return false;
return window.localStorage.getItem(BBOX_MODE_KEY) === "1";
});
useEffect(() => {
window.localStorage.setItem(BBOX_MODE_KEY, bboxMode ? "1" : "0");
}, [bboxMode]);
const [popupAspect, setPopupAspect] = useState<PopupAspect>(() => {
if (typeof window === "undefined") return "4:3";
const v = window.localStorage.getItem(POPUP_ASPECT_KEY) as PopupAspect | null;
@@ -1040,12 +1048,12 @@ function AppShell({
.catch((err) => setStatus(`Vertex delete failed: ${err}`));
return;
}
// Bulk hide: in focused-video OSM mode and not in vertex-edit submode,
// Del moves the multi-select set into the hidden bucket.
// Bulk action on selected roads in OSM mode (not in vertex-edit submode):
// focusedVideo != null → hide (non-destructive, video-scoped)
// focusedVideo == null → real DB delete with snapshot-based undo
if (
(e.key === "Delete" || e.key === "Backspace") &&
osmEditMode &&
focusedVideo !== null &&
editingRoadId === null &&
selectedRoadIds.size > 0
) {
@@ -1056,14 +1064,36 @@ function AppShell({
}
e.preventDefault();
const ids = Array.from(selectedRoadIds);
setHiddenRoadIds((prev) => {
const next = new Set(prev);
for (const id of ids) next.add(id);
return next;
});
setSelectedRoadIds(new Set());
pushOsmUndo({ kind: "unhide", ids, label: "hide selected" });
setStatus(`${ids.length} road(s) hidden.`);
if (focusedVideo !== null) {
setHiddenRoadIds((prev) => {
const next = new Set(prev);
for (const id of ids) next.add(id);
return next;
});
setSelectedRoadIds(new Set());
pushOsmUndo({ kind: "unhide", ids, label: "hide selected" });
setStatus(`${ids.length} road(s) hidden.`);
return;
}
// Global OSM mode: snapshot, delete each id, then refresh. Undo via
// the existing restore-roads upsert path resurrects them.
(async () => {
try {
await snapshotRoadsForUndo(ids, "delete selected roads");
for (const id of ids) {
await invoke("delete_osm_road", { id });
}
setSelectedRoadIds(new Set());
if (editingRoadId !== null && ids.includes(editingRoadId)) {
setEditingRoadId(null);
}
setMarkedVertices(new Set());
await refreshOsmRoads();
setStatus(`${ids.length} road(s) deleted. Use Undo to bring them back.`);
} catch (err) {
setStatus(`Bulk delete failed: ${err}`);
}
})();
return;
}
if ((e.key === "Delete" || e.key === "Backspace") && selectedLink) {
@@ -1093,6 +1123,46 @@ function AppShell({
toggleOsmEditModeRef.current();
return;
}
// 'B' / 'b' toggles bbox editing. No-op when popups are disabled.
if (e.key === "b" || e.key === "B") {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) {
return;
}
if (e.metaKey || e.ctrlKey || e.altKey) return;
e.preventDefault();
if (!popupEnabled) {
setStatus("Enable image popup first to use BBox mode.");
return;
}
setBboxMode((v) => !v);
return;
}
// 'H' / 'h' toggles the asset image popup. Same input-guard rules as 'O'.
if (e.key === "h" || e.key === "H") {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) {
return;
}
if (e.metaKey || e.ctrlKey || e.altKey) return;
e.preventDefault();
setPopupEnabled((v) => !v);
return;
}
// 'C' / 'c': fit to focused video (focus mode) or all data (global).
if (e.key === "c" || e.key === "C") {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) {
return;
}
if (e.metaKey || e.ctrlKey || e.altKey) return;
e.preventDefault();
fitToFocusOrAllRef.current();
return;
}
if (e.key !== "Escape") return;
if (lensActive) {
e.preventDefault();
@@ -1332,6 +1402,7 @@ function AppShell({
await Promise.all([
refreshDataCounts().catch(() => {}),
refreshVideoNames().catch(() => {}),
refreshVideoEntries().catch(() => {}),
refreshAssetNames().catch(() => {}),
]);
setStatus("Database reset.");
@@ -1504,6 +1575,27 @@ function AppShell({
}
}
// Annotated video list from the backend: raw names, deterministic numbers
// (timestamp + name sort), and completion flags shared across clients.
type VideoEntry = {
video_name: string;
video_number: number;
completed: boolean;
completed_at: string | null;
completed_by: string | null;
};
const [videoEntries, setVideoEntries] = useState<VideoEntry[]>([]);
const [completedVideosExpanded, setCompletedVideosExpanded] =
useState<boolean>(false);
async function refreshVideoEntries() {
try {
const list = await invoke<VideoEntry[]>("list_videos");
setVideoEntries(list);
} catch {
/* ignore */
}
}
async function refreshVideoNames() {
try {
const raw = await invoke<string[]>("list_video_names");
@@ -1550,6 +1642,7 @@ function AppShell({
refreshActions(),
refreshAssetNames(),
refreshVideoNames(),
refreshVideoEntries(),
refreshClasses(),
refreshDataCounts(),
];
@@ -1559,6 +1652,56 @@ function AppShell({
// Fly the map to a specific video (uses any combination of metadata polyline
// + asset bboxes, whichever exists).
// Fit the map to either the focused video or every loaded asset+track. The
// C shortcut and the on-map "Fit" button both call this. Held in a ref so
// the keydown handler always sees the current closure.
function fitToFocusOrAll() {
if (focusedVideo !== null) {
flyToVideo(focusedVideo);
return;
}
let mnLat = Infinity,
mxLat = -Infinity,
mnLng = Infinity,
mxLng = -Infinity;
for (const a of assets) {
if (a.deleted !== 0) continue;
const lats = [a.lat, a.start_lat, a.end_lat].filter(
(v): v is number => v !== null,
);
const lngs = [a.lng, a.start_lng, a.end_lng].filter(
(v): v is number => v !== null,
);
for (const v of lats) {
if (v < mnLat) mnLat = v;
if (v > mxLat) mxLat = v;
}
for (const v of lngs) {
if (v < mnLng) mnLng = v;
if (v > mxLng) mxLng = v;
}
}
for (const track of Object.values(metadataByVideo)) {
for (const [lng, lat] of track) {
if (lng < mnLng) mnLng = lng;
if (lng > mxLng) mxLng = lng;
if (lat < mnLat) mnLat = lat;
if (lat > mxLat) mxLat = lat;
}
}
if (!Number.isFinite(mnLat)) {
setStatus("No data loaded to fit.");
return;
}
setViewport({
center: [(mnLng + mxLng) / 2, (mnLat + mxLat) / 2],
zoom: 13,
});
setStatus("Fit to all data.");
}
const fitToFocusOrAllRef = useRef(fitToFocusOrAll);
fitToFocusOrAllRef.current = fitToFocusOrAll;
function flyToVideo(name: string) {
let mnLat = Infinity,
mxLat = -Infinity,
@@ -1674,6 +1817,7 @@ function AppShell({
refreshActions().catch(() => {});
refreshAssetNames().catch(() => {});
refreshVideoNames().catch(() => {});
refreshVideoEntries().catch(() => {});
refreshClasses().catch(() => {});
refreshDataCounts().catch(() => {});
// Per-video metadata persists in DB; load it so users don't have to
@@ -3004,6 +3148,7 @@ function AppShell({
setStatus(`Imported ${r.fixed} fixed + ${r.range} range = ${r.fixed + r.range} asset(s)`);
await Promise.all([
refreshVideoNames(),
refreshVideoEntries(),
refreshAssetNames(),
refreshClasses(),
]);
@@ -3048,6 +3193,7 @@ function AppShell({
// Pull in the new video / class names so the filter auto-includes them.
await Promise.all([
refreshVideoNames(),
refreshVideoEntries(),
refreshAssetNames(),
refreshClasses(),
]);
@@ -3281,6 +3427,7 @@ function AppShell({
onPlaceAt={handlePlaceAt}
imageFolder={imageFolder}
popupEnabled={popupEnabled}
bboxMode={bboxMode}
perfMode={perfMode}
anchorIds={Array.from(anchors.keys())}
colorBy={colorBy}
@@ -3305,11 +3452,28 @@ function AppShell({
setStatus(`Editing road #${id}. Drag the gold vertex dots.`);
}}
onRoadRightClick={(id) => {
// Right-click deletes the road from the DB. Snapshot first so the
// OSM undo ring can resurrect it via restore_road_state's upsert
// path (re-inserts the row with its original id + modified flag).
// Hide-by-Del in focused-video mode is a separate, non-destructive
// path.
// Behavior depends on whether a video is focused:
// focusedVideo != null → hide for this video (non-destructive,
// frontend-only, undoable via the unhide entry).
// focusedVideo == null → real DB delete with undo via the
// restore-roads upsert path. The explicit "Delete road"
// button + Del-on-selected stay aligned with this.
if (focusedVideo !== null) {
setHiddenRoadIds((prev) => {
const next = new Set(prev);
next.add(id);
return next;
});
setSelectedRoadIds((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
pushOsmUndo({ kind: "unhide", ids: [id], label: "hide road" });
setStatus(`Road #${id} hidden for "${focusedVideo}".`);
return;
}
(async () => {
try {
await snapshotRoadsForUndo([id], "delete road (right-click)");
@@ -3386,6 +3550,7 @@ function AppShell({
onPositionChange={handlePositionChange}
imageFolder={imageFolder}
popupEnabled={popupEnabled}
bboxMode={bboxMode}
perfMode={perfMode}
anchorIds={Array.from(anchors.keys())}
colorBy={
@@ -3396,6 +3561,96 @@ function AppShell({
}
/>
)}
{/*
Floating "Fit" button — same action as the C shortcut. Tooltip still
carries the wordy description; the button itself is just a map icon
so it stays compact at the bottom of the map.
*/}
<button
type="button"
onClick={() => fitToFocusOrAll()}
title={
focusedVideo
? `Fit map to "${focusedVideo}" (C)`
: "Fit map to all loaded data (C)"
}
aria-label={
focusedVideo
? `Fit map to ${focusedVideo}`
: "Fit map to all loaded data"
}
style={{
position: "absolute",
bottom: 16,
right: 16,
zIndex: 5,
width: 40,
height: 40,
padding: 0,
background: "rgba(44, 62, 80, 0.92)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.4)",
borderRadius: "50%",
fontSize: 20,
lineHeight: 1,
cursor: "pointer",
boxShadow: "0 2px 6px rgba(0,0,0,0.25)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
🗺
</button>
{/*
BBox mode quick-toggle — green when on, dark when off. Same as the
Settings checkbox + 'B' shortcut. Disabled when popups themselves
are off because there's nothing to render onto.
*/}
<button
type="button"
onClick={() => {
if (!popupEnabled) {
setStatus("Enable image popup first to use BBox mode.");
return;
}
setBboxMode((v) => !v);
}}
title={
!popupEnabled
? "Enable image popup first to use BBox mode"
: bboxMode
? "BBox mode ON — drag to draw/move/resize bounding boxes. Click to turn off (B)"
: "Click to turn on BBox mode (B)"
}
aria-label="Toggle BBox mode"
style={{
position: "absolute",
bottom: 16,
right: 66,
zIndex: 5,
width: 40,
height: 40,
padding: 0,
background: bboxMode
? "rgba(39, 174, 96, 0.95)"
: "rgba(44, 62, 80, 0.92)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.4)",
borderRadius: "50%",
fontSize: 13,
fontWeight: 700,
lineHeight: 1,
cursor: popupEnabled ? "pointer" : "not-allowed",
opacity: popupEnabled ? 1 : 0.5,
boxShadow: "0 2px 6px rgba(0,0,0,0.25)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
</button>
</div>
<button
@@ -3597,47 +3852,118 @@ function AppShell({
marginBottom: 4,
}}
/>
<div style={{ maxHeight: 240, overflowY: "auto" }}>
{Array.from(
new Set(
allVideoNames.concat(Object.keys(metadataByVideo)),
),
{(() => {
// Merge: videoEntries is the authoritative ordered list from
// the backend (number + completion). Names present only in
// metadataByVideo (track loaded but no assets imported yet)
// get pushed to the end with a synthetic number so the user
// can still pick them.
const known = new Set(
videoEntries.map((e) => displayVideo(e.video_name)),
);
const extras = Array.from(
new Set(Object.keys(metadataByVideo)),
)
.filter((v) => !known.has(displayVideo(v)))
.sort()
.filter((v) =>
videoSearch.trim() === ""
? true
: v
.toLowerCase()
.includes(videoSearch.trim().toLowerCase()),
)
.map((v) => {
const n = counts.byVideo.get(v) ?? 0;
return [v, n] as [string, number];
})
.map(([v, n]) => (
<label key={v} className="filter-row">
.map(
(v, i): VideoEntry => ({
video_name: v,
video_number: videoEntries.length + i + 1,
completed: false,
completed_at: null,
completed_by: null,
}),
);
const all = videoEntries.concat(extras);
const term = videoSearch.trim().toLowerCase();
const matchSearch = (e: VideoEntry) => {
if (term === "") return true;
const label = displayVideo(e.video_name).toLowerCase();
return (
label.includes(term) ||
String(e.video_number).includes(term)
);
};
const active = all.filter((e) => !e.completed && matchSearch(e));
const completed = all.filter(
(e) => e.completed && matchSearch(e),
);
const renderRow = (e: VideoEntry) => {
const label = displayVideo(e.video_name);
const n = counts.byVideo.get(label) ?? 0;
return (
<label key={e.video_name} className="filter-row">
<input
type="checkbox"
checked={filters.videos.has(v)}
checked={filters.videos.has(label)}
onChange={() =>
setFilters((f) => ({
...f,
videos: toggleSet(f.videos, v),
videos: toggleSet(f.videos, label),
}))
}
/>
<span className="label" title={v}>
{v}
<span
className="label"
title={
e.completed && e.completed_by
? `${e.video_name}\nCompleted by ${e.completed_by}${
e.completed_at ? ` on ${e.completed_at}` : ""
}`
: e.video_name
}
style={
e.completed
? { textDecoration: "line-through", opacity: 0.7 }
: undefined
}
>
<span style={{ color: "#7f8c8d", marginRight: 4 }}>
#{e.video_number}
</span>
{label}
</span>
<span className="count">{n}</span>
<button
type="button"
onClick={(e) => {
e.preventDefault();
flyToVideo(v);
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
invoke("set_video_completed", {
videoName: e.video_name,
completed: !e.completed,
})
.then(() => refreshVideoEntries())
.catch((err) =>
setStatus(`Mark complete failed: ${err}`),
);
}}
title={`Fly to ${v}`}
title={
e.completed
? "Click to mark as not complete"
: "Mark this video as reviewed"
}
style={{
marginLeft: 4,
padding: "0 6px",
fontSize: 12,
border: "1px solid #ccc",
borderRadius: 3,
background: e.completed ? "#27ae60" : "#fff",
color: e.completed ? "#fff" : "#27ae60",
cursor: "pointer",
}}
>
</button>
<button
type="button"
onClick={(ev) => {
ev.preventDefault();
flyToVideo(label);
}}
title={`Fly to ${label}`}
style={{
marginLeft: 4,
padding: "0 6px",
@@ -3651,8 +3977,60 @@ function AppShell({
</button>
</label>
))}
</div>
);
};
return (
<>
<div style={{ maxHeight: 240, overflowY: "auto" }}>
{active.map(renderRow)}
{active.length === 0 && (
<div
style={{
fontSize: 11,
color: "#7f8c8d",
padding: 4,
}}
>
(no active videos)
</div>
)}
</div>
{completed.length > 0 && (
<div style={{ marginTop: 6 }}>
<div
style={{
cursor: "pointer",
userSelect: "none",
fontSize: 12,
fontWeight: 600,
color: "#27ae60",
padding: "4px 0",
}}
onClick={() =>
setCompletedVideosExpanded((v) => !v)
}
title="Toggle the completed-videos section"
>
{completedVideosExpanded ? "▼" : "▶"} Completed (
{completed.length})
</div>
{completedVideosExpanded && (
<div
style={{
maxHeight: 200,
overflowY: "auto",
borderTop: "1px dashed #ccc",
paddingTop: 4,
}}
>
{completed.map(renderRow)}
</div>
)}
</div>
)}
</>
);
})()}
</>
)}
@@ -4045,6 +4423,58 @@ function AppShell({
/>
Show image popup when an asset is selected
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 6,
}}
title="When ON, the popup shows the full image and overlays an editable bounding box. Drag a corner/edge to resize, drag inside to move, drag empty space to draw new."
>
<input
type="checkbox"
checked={bboxMode}
onChange={(e) => setBboxMode(e.target.checked)}
disabled={!popupEnabled}
/>
BBox mode (edit bounding boxes on images)
</label>
<button
type="button"
onClick={async () => {
try {
const { save } = await import(
"@tauri-apps/plugin-dialog"
);
const p = await save({
title: "Export bboxes to JSON",
defaultPath: "bboxes.json",
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (!p) return;
const n = await invoke<number>("export_bboxes_to_json", {
path: p,
});
setStatus(`Exported ${n} bbox(es) to ${p}`);
} catch (e) {
setStatus(`Export failed: ${e}`);
}
}}
style={{
marginTop: 6,
fontSize: 12,
padding: "4px 8px",
border: "1px solid #16a085",
background: "#fff",
color: "#16a085",
borderRadius: 3,
cursor: "pointer",
}}
title="Write every bbox row to a sidecar JSON file. Source JSONs are never modified."
>
Export bboxes to JSON
</button>
<h4 style={{ marginTop: 12 }}>Quality thresholds</h4>
<label