bbox mode
This commit is contained in:
518
src/App.tsx
518
src/App.tsx
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user