osm edits
This commit is contained in:
624
src/App.tsx
624
src/App.tsx
@@ -118,6 +118,12 @@ function AppShell({
|
||||
const [compare, setCompare] = useState<boolean>(false);
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
const [scopePolygons, setScopePolygons] = useState<ScopePolygon[]>([]);
|
||||
const [dataCounts, setDataCounts] = useState<{
|
||||
fixed_assets: number;
|
||||
range_assets: number;
|
||||
osm_roads: number;
|
||||
scope_features: number;
|
||||
}>({ fixed_assets: 0, range_assets: 0, osm_roads: 0, scope_features: 0 });
|
||||
const [recentActions, setRecentActions] = useState<ActionRow[]>([]);
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [busy, setBusy] = useState<boolean>(false);
|
||||
@@ -143,6 +149,16 @@ function AppShell({
|
||||
features: [],
|
||||
});
|
||||
const [metadataTrack, setMetadataTrack] = useState<Array<[number, number]>>([]);
|
||||
// Per-video metadata polylines loaded from a multi-video JSON. When set,
|
||||
// snap-to-road and direction inference look up each asset's track by video.
|
||||
// The single-track `metadataTrack` is still used as a fallback (e.g. when
|
||||
// only one video is loaded via the legacy importer).
|
||||
const [metadataByVideo, setMetadataByVideo] = useState<
|
||||
Record<string, Array<[number, number]>>
|
||||
>({});
|
||||
const [selectedMetadataVideo, setSelectedMetadataVideo] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [layerVisibility, setLayerVisibility] = useState<LayerVisibility>({
|
||||
scope: true,
|
||||
metadata: true,
|
||||
@@ -176,9 +192,21 @@ function AppShell({
|
||||
const [namesExpanded, setNamesExpanded] = useState<boolean>(true);
|
||||
const [filterPanelOpen, setFilterPanelOpen] = useState<boolean>(true);
|
||||
const [videoSearch, setVideoSearch] = useState<string>("");
|
||||
const [gotoQuery, setGotoQuery] = useState<string>("");
|
||||
const [gotoPin, setGotoPin] = useState<{ lat: number; lng: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [markedVertices, setMarkedVertices] = useState<Set<number>>(new Set());
|
||||
const [simplifyTolM, setSimplifyTolM] = useState<number>(2);
|
||||
const [allAssetNames, setAllAssetNames] = useState<string[]>([]);
|
||||
const [osmEditMode, setOsmEditMode] = useState<boolean>(false);
|
||||
const [editingRoadId, setEditingRoadId] = useState<number | null>(null);
|
||||
// Reset the marked-vertex set whenever the user switches roads or leaves
|
||||
// OSM-edit mode. Indices are road-relative, so they're meaningless across
|
||||
// roads.
|
||||
useEffect(() => {
|
||||
setMarkedVertices(new Set());
|
||||
}, [editingRoadId, osmEditMode]);
|
||||
const [drawRoadMode, setDrawRoadMode] = useState<boolean>(false);
|
||||
const [drawRoadCoords, setDrawRoadCoords] = useState<
|
||||
Array<[number, number]>
|
||||
@@ -396,8 +424,12 @@ function AppShell({
|
||||
names.add(a.asset_name);
|
||||
if (vids.size > 1 && names.size > 1) break; // early-out: result will be "video"
|
||||
}
|
||||
// Single video visible → fall back to asset / side coloring so the user
|
||||
// can tell individual assets apart instead of seeing one solid color.
|
||||
if (vids.size <= 1 && names.size === 1) return "side";
|
||||
if (vids.size <= 1) return "asset";
|
||||
// Multiple videos visible → match each asset's color to its metadata
|
||||
// polyline (when loaded) by coloring by video.
|
||||
return "video";
|
||||
}, [filteredAssets]);
|
||||
|
||||
@@ -499,6 +531,29 @@ function AppShell({
|
||||
// ESC: deselect editing road (or cancel a draw-in-progress).
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (
|
||||
(e.key === "Delete" || e.key === "Backspace") &&
|
||||
osmEditMode &&
|
||||
editingRoadId !== null &&
|
||||
markedVertices.size > 0
|
||||
) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const tag = target?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const idx = Array.from(markedVertices);
|
||||
const roadId = editingRoadId;
|
||||
invoke<number>("delete_road_vertices", { id: roadId, indices: idx })
|
||||
.then((n) => {
|
||||
setStatus(`Removed ${n} vertex(s) from road #${roadId}`);
|
||||
setMarkedVertices(new Set());
|
||||
return refreshOsmRoads();
|
||||
})
|
||||
.catch((err) => setStatus(`Vertex delete failed: ${err}`));
|
||||
return;
|
||||
}
|
||||
if ((e.key === "Delete" || e.key === "Backspace") && selectedLink) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const tag = target?.tagName;
|
||||
@@ -523,6 +578,18 @@ function AppShell({
|
||||
setStatus("Lasso cancelled.");
|
||||
return;
|
||||
}
|
||||
if (selectedMetadataVideo !== null) {
|
||||
e.preventDefault();
|
||||
setSelectedMetadataVideo(null);
|
||||
setStatus("");
|
||||
return;
|
||||
}
|
||||
if (gotoPin !== null) {
|
||||
e.preventDefault();
|
||||
setGotoPin(null);
|
||||
setStatus("");
|
||||
return;
|
||||
}
|
||||
if (linkPickMode) {
|
||||
e.preventDefault();
|
||||
setLinkPickMode(false);
|
||||
@@ -541,7 +608,7 @@ function AppShell({
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [drawRoadMode, editingRoadId, linkPickMode, rightClickFirstId, lassoMode, selectedLink]);
|
||||
}, [drawRoadMode, editingRoadId, linkPickMode, rightClickFirstId, lassoMode, selectedLink, selectedMetadataVideo, gotoPin, osmEditMode, markedVertices]);
|
||||
|
||||
// Image prefetch: when an asset is selected, warm the browser cache for its
|
||||
// immediate neighbours so [/] navigation feels instant.
|
||||
@@ -887,11 +954,116 @@ function AppShell({
|
||||
refreshAssetNames(),
|
||||
refreshVideoNames(),
|
||||
refreshClasses(),
|
||||
refreshDataCounts(),
|
||||
];
|
||||
if (boundsRef.current) tasks.push(fetchBboxAssets(boundsRef.current));
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
// Fly the map to a specific video (uses any combination of metadata polyline
|
||||
// + asset bboxes, whichever exists).
|
||||
function flyToVideo(name: string) {
|
||||
let mnLat = Infinity,
|
||||
mxLat = -Infinity,
|
||||
mnLng = Infinity,
|
||||
mxLng = -Infinity;
|
||||
const track = metadataByVideo[name];
|
||||
if (track && track.length > 0) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
for (const a of assets) {
|
||||
if (a.deleted !== 0) continue;
|
||||
if (displayVideo(a.video_name) !== name) 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;
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(mnLat)) {
|
||||
setStatus(`No location for "${name}" (load metadata or assets first).`);
|
||||
return;
|
||||
}
|
||||
setViewport({
|
||||
center: [(mnLng + mxLng) / 2, (mnLat + mxLat) / 2],
|
||||
zoom: 14,
|
||||
});
|
||||
// If the video has a metadata track, select it so the user sees direction
|
||||
// arrows + S/E markers + name badge — same effect as tapping the line.
|
||||
if (metadataByVideo[name]) {
|
||||
setSelectedMetadataVideo(name);
|
||||
}
|
||||
setStatus(name);
|
||||
}
|
||||
|
||||
function handleGoto() {
|
||||
const q = gotoQuery.trim();
|
||||
if (q === "") return;
|
||||
// Try lat,lng first.
|
||||
const m = q.match(/^\s*(-?\d+(?:\.\d+)?)\s*[, ]\s*(-?\d+(?:\.\d+)?)\s*$/);
|
||||
if (m) {
|
||||
const lat = parseFloat(m[1]);
|
||||
const lng = parseFloat(m[2]);
|
||||
if (
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lng) &&
|
||||
lat >= -90 &&
|
||||
lat <= 90 &&
|
||||
lng >= -180 &&
|
||||
lng <= 180
|
||||
) {
|
||||
setViewport({ center: [lng, lat], zoom: 18 });
|
||||
setGotoPin({ lat, lng });
|
||||
setStatus(`📍 ${lat.toFixed(6)}, ${lng.toFixed(6)}`);
|
||||
return;
|
||||
}
|
||||
setStatus(`Out-of-range lat/lng: ${q}`);
|
||||
return;
|
||||
}
|
||||
// Else treat as a video name — exact, then prefix, then substring (case-insensitive).
|
||||
const videos = Object.keys(metadataByVideo).concat(allVideoNames);
|
||||
const lower = q.toLowerCase();
|
||||
const exact = videos.find((v) => v === q);
|
||||
const prefix = videos.find((v) => v.toLowerCase().startsWith(lower));
|
||||
const sub = videos.find((v) => v.toLowerCase().includes(lower));
|
||||
const hit = exact ?? prefix ?? sub;
|
||||
if (hit) {
|
||||
flyToVideo(hit);
|
||||
return;
|
||||
}
|
||||
setStatus(
|
||||
`No match for "${q}". Use "lat,lng" (e.g. 17.398, 78.347) or a video name.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshDataCounts() {
|
||||
try {
|
||||
const c = await invoke<{
|
||||
fixed_assets: number;
|
||||
range_assets: number;
|
||||
osm_roads: number;
|
||||
scope_features: number;
|
||||
}>("data_source_counts");
|
||||
setDataCounts(c);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refreshScopePolygons().catch((e) =>
|
||||
setStatus(`Scope load failed: ${e}`),
|
||||
@@ -901,6 +1073,7 @@ function AppShell({
|
||||
refreshAssetNames().catch(() => {});
|
||||
refreshVideoNames().catch(() => {});
|
||||
refreshClasses().catch(() => {});
|
||||
refreshDataCounts().catch(() => {});
|
||||
invoke<Asset[]>("list_assets")
|
||||
.then((list) => {
|
||||
if (list.length > 0) {
|
||||
@@ -1205,7 +1378,12 @@ function AppShell({
|
||||
maxDistanceM: 50,
|
||||
offsetM: roadOffsetMeters,
|
||||
centerlineNames: Array.from(centerlineNames),
|
||||
metadataTrack: metadataTrack.length >= 2 ? metadataTrack : null,
|
||||
metadataTrack:
|
||||
metadataByVideo[name]?.length >= 2
|
||||
? metadataByVideo[name]
|
||||
: metadataTrack.length >= 2
|
||||
? metadataTrack
|
||||
: null,
|
||||
});
|
||||
setStatus(
|
||||
`Snap by video: ${res.snapped} moved, ${res.skipped_far} too far, ${res.skipped_no_geom} skipped`,
|
||||
@@ -1715,7 +1893,15 @@ function AppShell({
|
||||
maxDistanceM: 50,
|
||||
offsetM: roadOffsetMeters,
|
||||
centerlineNames: Array.from(centerlineNames),
|
||||
metadataTrack: metadataTrack.length >= 2 ? metadataTrack : null,
|
||||
metadataTrack: (() => {
|
||||
// If only one video is selected in the filters, use that video's
|
||||
// track. Otherwise fall back to the concatenated single track.
|
||||
const sel = Array.from(filters.videos);
|
||||
if (sel.length === 1 && metadataByVideo[sel[0]]?.length >= 2) {
|
||||
return metadataByVideo[sel[0]];
|
||||
}
|
||||
return metadataTrack.length >= 2 ? metadataTrack : null;
|
||||
})(),
|
||||
});
|
||||
setStatus(
|
||||
`Bulk snap: ${res.snapped} moved, ${res.skipped_far} too far, ${res.skipped_no_geom} skipped`,
|
||||
@@ -1733,11 +1919,18 @@ function AppShell({
|
||||
setBusy(true);
|
||||
try {
|
||||
const useCenter = centerlineNames.has(selectedAsset.asset_name);
|
||||
const perVideo = metadataByVideo[selectedAsset.video_name];
|
||||
const trackForAsset =
|
||||
perVideo && perVideo.length >= 2
|
||||
? perVideo
|
||||
: metadataTrack.length >= 2
|
||||
? metadataTrack
|
||||
: null;
|
||||
const [lat, lng] = await invoke<[number, number]>("snap_to_road", {
|
||||
assetId: selectedAsset.id,
|
||||
maxDistanceM: 50,
|
||||
offsetM: useCenter ? 0 : roadOffsetMeters,
|
||||
metadataTrack: metadataTrack.length >= 2 ? metadataTrack : null,
|
||||
metadataTrack: trackForAsset,
|
||||
});
|
||||
setStatus(
|
||||
`Snapped to road @ ${lat.toFixed(6)}, ${lng.toFixed(6)}`,
|
||||
@@ -1778,6 +1971,58 @@ function AppShell({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportVideoMetadata() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setStatus("Picking per-video metadata JSON…");
|
||||
try {
|
||||
const picked = await open({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
filters: [{ name: "Per-video metadata JSON", extensions: ["json"] }],
|
||||
});
|
||||
if (typeof picked !== "string") {
|
||||
setStatus("");
|
||||
return;
|
||||
}
|
||||
setStatus("Parsing video polylines…");
|
||||
const map = await invoke<Record<string, Array<[number, number]>>>(
|
||||
"read_video_polylines_json",
|
||||
{ path: picked },
|
||||
);
|
||||
setMetadataByVideo(map);
|
||||
const keys = Object.keys(map);
|
||||
// Concatenated fallback for callers that don't yet pass a video.
|
||||
const flat: Array<[number, number]> = [];
|
||||
for (const k of keys) flat.push(...map[k]);
|
||||
setMetadataTrack(flat);
|
||||
// Recenter the map on the metadata bbox so the user can actually see
|
||||
// what they just imported (otherwise the polylines may sit outside the
|
||||
// current viewport and look "missing").
|
||||
if (flat.length > 0) {
|
||||
let minLng = Infinity, maxLng = -Infinity;
|
||||
let minLat = Infinity, maxLat = -Infinity;
|
||||
for (const [lng, lat] of flat) {
|
||||
if (lng < minLng) minLng = lng;
|
||||
if (lng > maxLng) maxLng = lng;
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
}
|
||||
setViewport({
|
||||
center: [(minLng + maxLng) / 2, (minLat + maxLat) / 2],
|
||||
zoom: 12,
|
||||
});
|
||||
}
|
||||
setStatus(
|
||||
`Loaded metadata for ${keys.length} video(s). Toggle "Metadata polyline" in Layers if it's off. Snap & direction now use each asset's matching video track.`,
|
||||
);
|
||||
} catch (e) {
|
||||
setStatus(`Video metadata import failed: ${e}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport(kind: "fixed" | "range") {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
@@ -1838,6 +2083,25 @@ function AppShell({
|
||||
scopePolygons={scopePolygons}
|
||||
osmRoads={osmRoads}
|
||||
metadataTrack={metadataTrack}
|
||||
metadataTracks={metadataByVideo}
|
||||
selectedMetadataVideo={selectedMetadataVideo}
|
||||
gotoPin={gotoPin}
|
||||
markedVertices={markedVertices}
|
||||
onVertexShiftClick={(idx) => {
|
||||
setMarkedVertices((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(idx)) next.delete(idx);
|
||||
else next.add(idx);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onMetadataLineClick={(name) => {
|
||||
setSelectedMetadataVideo((cur) => {
|
||||
const next = cur === name ? null : name;
|
||||
setStatus(next === null ? "" : name);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
layerVisibility={layerVisibility}
|
||||
selectedAsset={selectedAsset}
|
||||
onSelect={(a) => {
|
||||
@@ -1928,6 +2192,25 @@ function AppShell({
|
||||
scopePolygons={scopePolygons}
|
||||
osmRoads={osmRoads}
|
||||
metadataTrack={metadataTrack}
|
||||
metadataTracks={metadataByVideo}
|
||||
selectedMetadataVideo={selectedMetadataVideo}
|
||||
gotoPin={gotoPin}
|
||||
markedVertices={markedVertices}
|
||||
onVertexShiftClick={(idx) => {
|
||||
setMarkedVertices((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(idx)) next.delete(idx);
|
||||
else next.add(idx);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onMetadataLineClick={(name) => {
|
||||
setSelectedMetadataVideo((cur) => {
|
||||
const next = cur === name ? null : name;
|
||||
setStatus(next === null ? "" : name);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
layerVisibility={layerVisibility}
|
||||
selectedAsset={selectedAsset}
|
||||
onSelect={(a) => {
|
||||
@@ -2148,7 +2431,12 @@ function AppShell({
|
||||
}}
|
||||
/>
|
||||
<div style={{ maxHeight: 240, overflowY: "auto" }}>
|
||||
{allVideoNames
|
||||
{Array.from(
|
||||
new Set(
|
||||
allVideoNames.concat(Object.keys(metadataByVideo)),
|
||||
),
|
||||
)
|
||||
.sort()
|
||||
.filter((v) =>
|
||||
videoSearch.trim() === ""
|
||||
? true
|
||||
@@ -2176,6 +2464,25 @@ function AppShell({
|
||||
{v}
|
||||
</span>
|
||||
<span className="count">{n}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
flyToVideo(v);
|
||||
}}
|
||||
title={`Fly to ${v}`}
|
||||
style={{
|
||||
marginLeft: 4,
|
||||
padding: "0 6px",
|
||||
fontSize: 11,
|
||||
border: "1px solid #ccc",
|
||||
borderRadius: 3,
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
⤢
|
||||
</button>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -2378,9 +2685,22 @@ function AppShell({
|
||||
handleImportScope();
|
||||
}}
|
||||
disabled={busy}
|
||||
title="Import a KML scope polygon or polyline (geometric only)."
|
||||
>
|
||||
KML scope…
|
||||
</button>
|
||||
<button
|
||||
className="primary-btn"
|
||||
style={{ background: "#9b59b6" }}
|
||||
onClick={() => {
|
||||
setImportsOpen(false);
|
||||
handleImportVideoMetadata();
|
||||
}}
|
||||
disabled={busy}
|
||||
title="Import per-video metadata polylines ({video_name: [[[lat,lng],...]]}). Snap & direction inference will use each asset's matching video track."
|
||||
>
|
||||
Per-video metadata (JSON)…
|
||||
</button>
|
||||
<button
|
||||
className="primary-btn"
|
||||
style={{ background: "#34495e" }}
|
||||
@@ -2691,6 +3011,45 @@ function AppShell({
|
||||
<span>Compare basemaps (side-by-side)</span>
|
||||
</label>
|
||||
|
||||
<h3 style={{ marginTop: 16 }}>Go to</h3>
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="lat, lng — or video name"
|
||||
value={gotoQuery}
|
||||
onChange={(e) => setGotoQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleGoto();
|
||||
}}
|
||||
list="goto-video-options"
|
||||
autoComplete="off"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 4,
|
||||
border: "1px solid #ccc",
|
||||
borderRadius: 3,
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
<datalist id="goto-video-options">
|
||||
{Array.from(
|
||||
new Set(Object.keys(metadataByVideo).concat(allVideoNames)),
|
||||
)
|
||||
.sort()
|
||||
.map((v) => (
|
||||
<option key={v} value={v} />
|
||||
))}
|
||||
</datalist>
|
||||
<button
|
||||
className="primary-btn"
|
||||
style={{ padding: "2px 8px", fontSize: 11 }}
|
||||
onClick={handleGoto}
|
||||
disabled={busy || gotoQuery.trim() === ""}
|
||||
>
|
||||
Go
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: 16 }}>Data</h3>
|
||||
<button
|
||||
className="primary-btn"
|
||||
@@ -2699,6 +3058,166 @@ function AppShell({
|
||||
>
|
||||
Import…
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 6,
|
||||
padding: 6,
|
||||
background: "#f6f8fa",
|
||||
border: "1px solid #d0d7de",
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>Loaded data</div>
|
||||
{([
|
||||
{
|
||||
label: "Fixed assets",
|
||||
count: dataCounts.fixed_assets,
|
||||
onReplace: () => {
|
||||
setImportsOpen(false);
|
||||
handleImport("fixed");
|
||||
},
|
||||
onClear: async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Permanently delete all ${dataCounts.fixed_assets} fixed asset(s)?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const n = await invoke<number>("clear_assets_by_type", {
|
||||
assetType: "fixed",
|
||||
});
|
||||
setStatus(`Cleared ${n} fixed asset(s)`);
|
||||
await refreshAfterMutation();
|
||||
} catch (e) {
|
||||
setStatus(`Clear failed: ${e}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Range assets",
|
||||
count: dataCounts.range_assets,
|
||||
onReplace: () => {
|
||||
setImportsOpen(false);
|
||||
handleImport("range");
|
||||
},
|
||||
onClear: async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Permanently delete all ${dataCounts.range_assets} range asset(s)?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const n = await invoke<number>("clear_assets_by_type", {
|
||||
assetType: "range",
|
||||
});
|
||||
setStatus(`Cleared ${n} range asset(s)`);
|
||||
await refreshAfterMutation();
|
||||
} catch (e) {
|
||||
setStatus(`Clear failed: ${e}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "OSM roads",
|
||||
count: dataCounts.osm_roads,
|
||||
onReplace: () => handleImportOsm(),
|
||||
onClear: async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Permanently delete all ${dataCounts.osm_roads} OSM road(s)?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const n = await invoke<number>("clear_osm_roads");
|
||||
setStatus(`Cleared ${n} OSM road(s)`);
|
||||
await refreshOsmRoads();
|
||||
await refreshDataCounts();
|
||||
} catch (e) {
|
||||
setStatus(`Clear failed: ${e}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "KML scope",
|
||||
count: dataCounts.scope_features,
|
||||
onReplace: () => handleImportScope(),
|
||||
onClear: async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Permanently delete all ${dataCounts.scope_features} scope feature(s)?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const n = await invoke<number>("clear_scope_polygons");
|
||||
setStatus(`Cleared ${n} scope feature(s)`);
|
||||
await refreshScopePolygons();
|
||||
await refreshAfterMutation();
|
||||
} catch (e) {
|
||||
setStatus(`Clear failed: ${e}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Per-video metadata",
|
||||
count: Object.keys(metadataByVideo).length,
|
||||
onReplace: () => handleImportVideoMetadata(),
|
||||
onClear: () => {
|
||||
setMetadataByVideo({});
|
||||
setMetadataTrack([]);
|
||||
setSelectedMetadataVideo(null);
|
||||
setStatus("Cleared per-video metadata");
|
||||
},
|
||||
},
|
||||
] as const).map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1 }}>
|
||||
{row.label}:{" "}
|
||||
<strong>
|
||||
{row.count > 0 ? row.count.toLocaleString() : "—"}
|
||||
</strong>
|
||||
</span>
|
||||
<button
|
||||
className="primary-btn"
|
||||
style={{
|
||||
padding: "2px 6px",
|
||||
fontSize: 10,
|
||||
background: "#0969da",
|
||||
}}
|
||||
onClick={row.onReplace}
|
||||
disabled={busy}
|
||||
title={row.count > 0 ? "Replace with a new file" : "Add"}
|
||||
>
|
||||
{row.count > 0 ? "Replace…" : "Add…"}
|
||||
</button>
|
||||
<button
|
||||
className="primary-btn"
|
||||
style={{
|
||||
padding: "2px 6px",
|
||||
fontSize: 10,
|
||||
background: "#cf222e",
|
||||
}}
|
||||
onClick={row.onClear}
|
||||
disabled={busy || row.count === 0}
|
||||
title="Permanently remove this data source"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="primary-btn"
|
||||
style={{
|
||||
@@ -2733,6 +3252,101 @@ function AppShell({
|
||||
>
|
||||
Flip direction
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 6,
|
||||
padding: 6,
|
||||
background: "#fff8e1",
|
||||
border: "1px solid #f1c40f",
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
Bulk vertex tools
|
||||
</div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
Shift-click vertices to mark (red ✕). Press{" "}
|
||||
<strong>Del</strong> to remove all marked.
|
||||
</div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
Currently marked:{" "}
|
||||
<strong>{markedVertices.size}</strong>
|
||||
{markedVertices.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
padding: "0 6px",
|
||||
fontSize: 10,
|
||||
border: "1px solid #ccc",
|
||||
borderRadius: 3,
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => setMarkedVertices(new Set())}
|
||||
>
|
||||
Clear marks
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
marginTop: 6,
|
||||
}}
|
||||
title="Douglas-Peucker tolerance in metres. Higher = more aggressive simplification."
|
||||
>
|
||||
<span>Simplify (m):</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={20}
|
||||
step={0.5}
|
||||
value={simplifyTolM}
|
||||
onChange={(e) =>
|
||||
setSimplifyTolM(parseFloat(e.target.value))
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: 36, textAlign: "right" }}>
|
||||
{simplifyTolM} m
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
className="primary-btn"
|
||||
style={{
|
||||
marginTop: 4,
|
||||
width: "100%",
|
||||
background: "#16a085",
|
||||
fontSize: 11,
|
||||
}}
|
||||
onClick={async () => {
|
||||
if (editingRoadId === null) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const [before, after] = await invoke<[number, number]>(
|
||||
"simplify_road",
|
||||
{ id: editingRoadId, toleranceM: simplifyTolM },
|
||||
);
|
||||
setStatus(
|
||||
`Road #${editingRoadId}: ${before} → ${after} vertices (${before - after} removed)`,
|
||||
);
|
||||
setMarkedVertices(new Set());
|
||||
await refreshOsmRoads();
|
||||
} catch (e) {
|
||||
setStatus(`Simplify failed: ${e}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
Simplify this road
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="primary-btn"
|
||||
style={{ marginTop: 6, background: "#7f8c8d" }}
|
||||
|
||||
Reference in New Issue
Block a user