lasso bugs
This commit is contained in:
556
src/App.tsx
556
src/App.tsx
@@ -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 L↔R
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user