lasso bugs

This commit is contained in:
2026-06-10 19:54:10 +05:30
parent ea7730bef9
commit 49a4b51d75
3 changed files with 904 additions and 12 deletions

View File

@@ -584,6 +584,10 @@ function AppShell({
}, [autoLinkMaxM]);
const [lassoListOpen, setLassoListOpen] = useState<boolean>(false);
const [showDeleted, setShowDeleted] = useState<boolean>(false);
// '?' opens a cheat-sheet modal listing every keyboard shortcut. We track
// it as plain boolean state so the same handler can toggle on key press
// and close on backdrop click.
const [showShortcutsHelp, setShowShortcutsHelp] = useState<boolean>(false);
// Pulse animation for "Blink modified points". A simple bool toggle drives
// a setInterval in an effect; the tick is plumbed through to MapView so its
// dedicated overlay layer redraws every ~600 ms with alternating opacity.
@@ -1209,6 +1213,112 @@ function AppShell({
toggleOsmEditModeRef.current();
return;
}
// '?' (or Shift+/ on US layouts) opens the keyboard cheat-sheet.
// Toggles, so pressing again closes it. Always allowed except when
// typing into a text field.
if (e.key === "?" || (e.key === "/" && e.shiftKey)) {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) {
return;
}
e.preventDefault();
setShowShortcutsHelp((v) => !v);
return;
}
// 'A' toggles the selected asset as a GPS-correction anchor. Same
// toggle the "Mark as anchor" button does, just one-tap: pick the
// asset, press A, drag it to the right spot, press Distribute.
if (
(e.key === "a" || e.key === "A") &&
selectedAsset &&
!osmEditMode
) {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) {
return;
}
if (e.ctrlKey || e.metaKey || e.altKey) return;
e.preventDefault();
setAnchors((prev) => {
const next = new Map(prev);
if (next.has(selectedAsset.id)) {
next.delete(selectedAsset.id);
showToast("Anchor cleared");
} else if (
selectedAsset.lat !== null &&
selectedAsset.lng !== null
) {
next.set(selectedAsset.id, {
origLat: selectedAsset.lat,
origLng: selectedAsset.lng,
rowId: selectedAsset.row_id,
});
showToast("Marked as anchor");
}
return next;
});
return;
}
// Auto-link shortcuts. Require an active lasso selection (and its
// synthesized polygon — circle/bbox/polygon all set lassoPolygonPoints
// now) so we know the region to link inside. Modifiers pick the
// splitter: plain L = side (Left↔Right), Shift+L = by video,
// Alt+L = nearest (no split).
if (
(e.key === "l" || e.key === "L") &&
lassoSelected &&
lassoPolygonPoints &&
lassoPolygonPoints.length >= 3
) {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) {
return;
}
if (e.ctrlKey || e.metaKey) return;
e.preventDefault();
const mode: "side" | "video" | "none" = e.altKey
? "none"
: e.shiftKey
? "video"
: "side";
void handleAutoLink(mode);
return;
}
// 'K' clears links on every asset in the active lasso selection.
// Mirrors the "Clear links" button so it's a 1-tap undo after a
// mis-linked auto-link run.
if (
(e.key === "k" || e.key === "K") &&
lassoSelected &&
lassoSelected.ids.length > 0
) {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) {
return;
}
if (e.ctrlKey || e.metaKey || e.altKey) return;
e.preventDefault();
const ids = lassoSelected.ids.slice();
(async () => {
let n = 0;
for (const id of ids) {
try {
await invoke("clear_link", { id });
n++;
} catch {
/* asset wasn't linked; ignore */
}
}
setStatus(`Cleared links on ${n} asset(s)`);
showToast(`Cleared ${n} link(s)`);
await refreshAfterMutation();
})();
return;
}
// 'R' / 'r' opens the rename modal for the currently selected asset.
// Input-guard pattern matches the other shortcuts.
if (e.key === "r" || e.key === "R") {
@@ -1339,6 +1449,11 @@ function AppShell({
return;
}
if (e.key !== "Escape") return;
if (showShortcutsHelp) {
e.preventDefault();
setShowShortcutsHelp(false);
return;
}
if (lensActive) {
e.preventDefault();
setLensActive(false);
@@ -1384,7 +1499,7 @@ function AppShell({
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [drawRoadMode, editingRoadId, linkPickMode, rightClickFirstId, lassoMode, selectedLink, selectedMetadataVideo, gotoPin, osmEditMode, markedVertices, focusedVideo, selectedRoadIds, lensActive, selectedAsset, selectedAssetId, popupEnabled, multiSelectedIds]);
}, [drawRoadMode, editingRoadId, linkPickMode, rightClickFirstId, lassoMode, selectedLink, selectedMetadataVideo, gotoPin, osmEditMode, markedVertices, focusedVideo, selectedRoadIds, lensActive, selectedAsset, selectedAssetId, popupEnabled, multiSelectedIds, lassoSelected, lassoPolygonPoints, showShortcutsHelp]);
// Image prefetch: when an asset is selected, warm the browser cache for its
// immediate neighbours so [/] navigation feels instant.
@@ -2610,6 +2725,21 @@ function AppShell({
if (haversine(center, [a.lng, a.lat]) <= radius) ids.push(a.id);
}
label = `circle r=${radius.toFixed(0)} m`;
// Stash a polygon approximation of the circle so polygon-only actions
// (Auto-link, anchor-Distribute) light up for circle lasso too. Same
// 64-vertex approximation we already use in the OSM-edit-mode lasso.
const m_per_lat = 111_320;
const m_per_lng = 111_320 * Math.cos(toRad(center[1]));
const N = 64;
const poly: Array<[number, number]> = [];
for (let i = 0; i < N; i++) {
const a = (i / N) * Math.PI * 2;
poly.push([
center[0] + (radius * Math.cos(a)) / m_per_lng,
center[1] + (radius * Math.sin(a)) / m_per_lat,
]);
}
setLassoPolygonPoints(poly);
} else if (shape === "rect") {
const [a, b] = points;
const minLng = Math.min(a[0], b[0]);
@@ -2632,6 +2762,13 @@ function AppShell({
ids.push(x.id);
}
label = "bbox";
// Bbox as a 4-vertex polygon for Auto-link + region-scoped actions.
setLassoPolygonPoints([
[minLng, minLat],
[maxLng, minLat],
[maxLng, maxLat],
[minLng, maxLat],
]);
} else {
// polygon: ray-casting test
const verts = points;
@@ -2821,15 +2958,23 @@ function AppShell({
anchorList.sort((a, b) => naturalCompare(a.rowId, b.rowId));
const anchorIds = new Set(anchorList.map((a) => a.id));
// Build the corrections payload for every visible filtered asset that
// isn't itself an anchor (anchors already moved, no need to re-translate).
// Build the corrections payload. When a lasso selection is active we
// scope to those ids — anchor moves are meant to correct the assets the
// user explicitly bracketed, NOT every other point that happens to be
// visible. Without an active lasso the legacy behavior (all live filtered
// assets) still applies for one-anchor uniform shifts.
const lassoIdSet =
lassoSelected && lassoSelected.ids.length > 0
? new Set(lassoSelected.ids)
: null;
const targets = filteredAssets
.filter(
(a) =>
a.deleted === 0 &&
a.lat !== null &&
a.lng !== null &&
!anchorIds.has(a.id),
!anchorIds.has(a.id) &&
(lassoIdSet === null || lassoIdSet.has(a.id)),
)
.slice()
.sort((a, b) => naturalCompare(a.row_id, b.row_id));
@@ -2938,6 +3083,98 @@ function AppShell({
}
}
type EqualizationProposal = {
asset_id: number;
orig_lat: number;
orig_lng: number;
new_lat: number;
new_lng: number;
displacement_m: number;
};
type GapAnomaly = {
before_id: number;
after_id: number;
gap_m: number;
expected_missing: number;
};
type EqualizationResult = {
proposals: EqualizationProposal[];
anomalies: GapAnomaly[];
median_gap_m: number;
arc_length_m: number;
road_id: number;
lateral_offset_m: number;
};
const [equalizeReport, setEqualizeReport] =
useState<EqualizationResult | null>(null);
// Convert an EqualizationResult into bulk_translate corrections (id, dlat,
// dlng). Shared between the auto-apply path (no anomalies) and the
// user-confirms path inside the modal.
async function applyEqualization(result: EqualizationResult) {
const corrections = result.proposals.map((p) => ({
id: p.asset_id,
dlat: p.new_lat - p.orig_lat,
dlng: p.new_lng - p.orig_lng,
}));
if (corrections.length === 0) {
showToast("Nothing to apply");
return;
}
const n = await invoke<number>("bulk_translate", { corrections });
setStatus(
`Equalized ${n} asset(s) along road #${result.road_id} — arc ${result.arc_length_m.toFixed(0)} m, spacing ${(result.arc_length_m / (corrections.length + 1)).toFixed(1)} m`,
);
showToast(`Equalized ${n} asset(s)`);
await refreshAfterMutation();
}
async function handleEqualizeAlongRoad() {
if (anchors.size !== 2) {
setStatus("Mark exactly two anchors (press A on each) before equalizing.");
return;
}
if (!lassoSelected || lassoSelected.ids.length === 0) {
setStatus("Lasso the assets you want to redistribute between the anchors.");
return;
}
const anchorIds = Array.from(anchors.keys());
const [anchorAId, anchorBId] = anchorIds;
// Drop anchor ids from the target list — anchors stay where the user
// moved them; equalize redistributes the others.
const assetIds = lassoSelected.ids.filter(
(id) => id !== anchorAId && id !== anchorBId,
);
if (assetIds.length === 0) {
setStatus("Lasso must include at least one non-anchor asset.");
return;
}
setBusy(true);
try {
const result = await invoke<EqualizationResult>(
"equalize_along_road",
{
anchorAId,
anchorBId,
assetIds,
maxRoadDistanceM: 50,
},
);
if (result.anomalies.length === 0) {
// No discrepancies → apply immediately.
await applyEqualization(result);
} else {
// Show the gap report so the user can decide.
setEqualizeReport(result);
}
} catch (e) {
setStatus(`Equalize failed: ${e}`);
showToast(`Equalize failed: ${e}`);
} finally {
setBusy(false);
}
}
async function handleAutoLink(splitBy: "side" | "video" | "none") {
if (!lassoPolygonPoints || lassoPolygonPoints.length < 3) {
setStatus("Use the polygon lasso to define a region first.");
@@ -3957,6 +4194,15 @@ function AppShell({
>
</button>
<button
className="gear-btn"
onClick={() => setShowShortcutsHelp(true)}
aria-label="Keyboard shortcuts"
title="Keyboard shortcuts (?)"
style={{ right: 96 }}
>
?
</button>
<div
className="user-chip"
title={`Logged in as ${currentUser.displayName} (@${currentUser.username}). Click to logout.`}
@@ -3976,7 +4222,26 @@ function AppShell({
>
</button>
<h3>Filters</h3>
<h3 style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ flex: 1 }}>Filters</span>
<button
type="button"
onClick={() => setShowShortcutsHelp(true)}
title="Show keyboard shortcuts (?)"
style={{
fontSize: 11,
padding: "0 6px",
border: "1px solid #bdc3c7",
background: "#fff",
color: "#34495e",
borderRadius: 10,
cursor: "pointer",
lineHeight: "16px",
}}
>
? Shortcuts
</button>
</h3>
<div className="filter-summary">
Showing {filteredAssets.length} / {assets.length} loaded
<button
@@ -4451,6 +4716,261 @@ function AppShell({
</button>
)}
{equalizeReport && (
<div
className="modal-backdrop"
onClick={() => setEqualizeReport(null)}
>
<div
className="modal-card"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: 560 }}
>
<button
className="modal-close"
onClick={() => setEqualizeReport(null)}
>
</button>
<h2>Spacing anomalies detected</h2>
<div className="status" style={{ marginBottom: 8 }}>
Median spacing along the road:{" "}
<strong>{equalizeReport.median_gap_m.toFixed(1)} m</strong> ·
arc length{" "}
<strong>{equalizeReport.arc_length_m.toFixed(0)} m</strong> ·
lateral offset{" "}
<strong>
{Math.abs(equalizeReport.lateral_offset_m).toFixed(1)} m{" "}
{equalizeReport.lateral_offset_m >= 0 ? "left" : "right"}
</strong>
</div>
<p style={{ fontSize: 12, color: "#444" }}>
{equalizeReport.anomalies.length} gap(s) are much larger than the
median likely missed marker(s) in the original labelling. You
can still apply the equalization, but the known assets will be
spread evenly across the gap.
</p>
<div
style={{
maxHeight: 220,
overflowY: "auto",
border: "1px solid #ddd",
borderRadius: 4,
padding: 6,
fontSize: 12,
}}
>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "#666" }}>
<th style={{ padding: "2px 6px" }}>Between</th>
<th style={{ padding: "2px 6px" }}>Gap</th>
<th style={{ padding: "2px 6px" }}>Likely missing</th>
</tr>
</thead>
<tbody>
{equalizeReport.anomalies.map((g, i) => {
const beforeRow = assets.find((a) => a.id === g.before_id);
const afterRow = assets.find((a) => a.id === g.after_id);
const label = (a: typeof beforeRow) =>
a
? `${a.asset_name} · ${a.row_id.slice(-6)}`
: "(unknown)";
return (
<tr
key={`${g.before_id}-${g.after_id}-${i}`}
style={{ borderTop: "1px dotted #eee" }}
>
<td style={{ padding: "3px 6px" }}>
{label(beforeRow)} {label(afterRow)}
</td>
<td
style={{
padding: "3px 6px",
color: "#c0392b",
fontWeight: 600,
}}
>
{g.gap_m.toFixed(1)} m
</td>
<td style={{ padding: "3px 6px" }}>
{g.expected_missing}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div
style={{
display: "flex",
gap: 6,
marginTop: 12,
justifyContent: "flex-end",
}}
>
<button
className="primary-btn"
style={{ background: "#bdc3c7", color: "#222" }}
onClick={() => setEqualizeReport(null)}
disabled={busy}
>
Cancel
</button>
<button
className="primary-btn"
style={{ background: "#16a085" }}
onClick={async () => {
const r = equalizeReport;
setEqualizeReport(null);
setBusy(true);
try {
await applyEqualization(r);
} catch (e) {
setStatus(`Apply failed: ${e}`);
showToast(`Apply failed: ${e}`);
} finally {
setBusy(false);
}
}}
disabled={busy}
>
Apply anyway ({equalizeReport.proposals.length})
</button>
</div>
</div>
</div>
)}
{showShortcutsHelp && (
<div
className="modal-backdrop"
onClick={() => setShowShortcutsHelp(false)}
>
<div
className="modal-card"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: 620 }}
>
<button
className="modal-close"
onClick={() => setShowShortcutsHelp(false)}
>
</button>
<h2>Keyboard shortcuts</h2>
<div className="status" style={{ marginBottom: 8 }}>
Press <kbd>?</kbd> anywhere to open this dialog. <kbd>?</kbd>{" "}
again or <kbd>Esc</kbd> closes it. Hover any button in the side
panels for tooltips.
</div>
{(
[
{
group: "Selection & navigation",
rows: [
["[", "Previous navigable asset"],
["]", "Next navigable asset"],
["Ctrl/⌘ + click", "Add asset to multi-select halo"],
["C", "Fit map to focused video / all data"],
["Esc", "Cancel current mode (lasso, link-pick, draw, OSM editing, magnifier, this dialog)"],
],
},
{
group: "Asset actions",
rows: [
["R", "Rename selected asset"],
["A", "Toggle anchor on selected asset (then drag → Distribute)"],
["Delete / Backspace", "Delete selected / multi-selected asset(s)"],
],
},
{
group: "Image popup & bbox",
rows: [
["H", "Toggle image popup (works while Ctrl-held)"],
["B", "Toggle bbox edit mode (popup must be on)"],
],
},
{
group: "Lasso region",
rows: [
["L", "Auto-link L↔R inside lasso"],
["Shift + L", "Auto-link by video inside lasso"],
["Alt + L", "Auto-link nearest inside lasso"],
["K", "Clear links on lasso selection"],
],
},
{
group: "OSM edit mode",
rows: [
["O", "Toggle OSM edit mode"],
["Delete / Backspace", "Delete marked vertices, then selected roads"],
["Shift + click on road", "Insert a vertex at click point"],
],
},
{
group: "Other",
rows: [
["?", "Show / hide this cheat-sheet"],
["Delete (with link selected)", "Unlink the selected pair"],
],
},
] as Array<{ group: string; rows: Array<[string, string]> }>
).map((sec) => (
<div key={sec.group} style={{ marginBottom: 10 }}>
<h4
style={{
margin: "8px 0 4px",
fontSize: 11,
textTransform: "uppercase",
color: "#555",
letterSpacing: 0.5,
}}
>
{sec.group}
</h4>
<table
style={{
width: "100%",
fontSize: 12,
borderCollapse: "collapse",
}}
>
<tbody>
{sec.rows.map(([key, label]) => (
<tr key={key + label}>
<td
style={{
width: 180,
padding: "3px 6px 3px 0",
verticalAlign: "top",
}}
>
<kbd
style={{
fontFamily: "monospace",
background: "#ecf0f1",
border: "1px solid #bdc3c7",
borderRadius: 3,
padding: "1px 6px",
fontSize: 11,
}}
>
{key}
</kbd>
</td>
<td style={{ padding: "3px 0" }}>{label}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
</div>
)}
{renameOpen && (
<div
className="modal-backdrop"
@@ -5910,6 +6430,22 @@ function AppShell({
Clear anchors
</button>
</div>
{anchors.size === 2 && lassoSelected && (
<button
className="primary-btn"
style={{
marginTop: 4,
width: "100%",
background: "#16a085",
fontSize: 11,
}}
onClick={handleEqualizeAlongRoad}
disabled={busy || lassoSelected.ids.length === 0}
title="Project the two anchors + lassoed assets onto the nearest OSM road and redistribute them at constant arc-length spacing. Runs a gap check first; if discrepancies are found you'll be prompted."
>
Equalize along road
</button>
)}
</div>
)}
<button
@@ -6206,7 +6742,7 @@ function AppShell({
style={{ background: "#16a085", flex: 1, fontSize: 11 }}
onClick={() => handleAutoLink("side")}
disabled={busy}
title="Auto-pair Left↔Right within this polygon by proximity"
title="Auto-pair Left↔Right within this region by proximity (L)"
>
Auto-link LR
</button>
@@ -6215,7 +6751,7 @@ function AppShell({
style={{ background: "#1abc9c", flex: 1, fontSize: 11 }}
onClick={() => handleAutoLink("video")}
disabled={busy}
title="Auto-pair across the two videos in this polygon"
title="Auto-pair across the two videos in this region (Shift+L)"
>
Auto-link by video
</button>
@@ -6224,7 +6760,7 @@ function AppShell({
style={{ background: "#16a085", flex: 1, fontSize: 11 }}
onClick={() => handleAutoLink("none")}
disabled={busy}
title="Pair nearest neighbours in the polygon, ignoring side and video. Use when video_name is missing."
title="Pair nearest neighbours in the region, ignoring side and video. Use when video_name is missing. (Alt+L)"
>
Auto-link nearest
</button>
@@ -6256,7 +6792,7 @@ function AppShell({
await refreshAfterMutation();
}}
disabled={busy || lassoSelected.ids.length === 0}
title="Remove auto-link / locked-link from every asset in the lasso"
title="Remove auto-link / locked-link from every asset in the lasso (K)"
>
Clear links
</button>
@@ -6647,7 +7183,7 @@ function AppShell({
});
}}
disabled={busy}
title="Mark this asset as a GPS-correction anchor. Drag it after marking, then press Distribute to propagate the offset to other visible assets."
title="Mark this asset as a GPS-correction anchor. Drag it after marking, then press Distribute to propagate the offset to other visible assets. (A)"
>
{isAnchor ? "★ Anchor (click to unmark)" : "Mark as anchor"}
</button>