osm undo option
This commit is contained in:
141
src/App.tsx
141
src/App.tsx
@@ -381,16 +381,19 @@ function AppShell({
|
||||
}, [editingRoadId, osmEditMode]);
|
||||
useEffect(() => {
|
||||
if (!osmEditMode) {
|
||||
// Clear transient visual selection. Hidden roads and the undo ring
|
||||
// buffer survive exit/re-entry so the user can pop into the layer to
|
||||
// sanity-check elsewhere and come back without losing their work.
|
||||
setSelectedRoadIds(new Set());
|
||||
setHiddenRoadIds(new Set());
|
||||
setOsmUndoStack([]);
|
||||
}
|
||||
}, [osmEditMode]);
|
||||
|
||||
// Per-session OSM edit undo: ring buffer (last 3) covering flip / simplify
|
||||
// / vertex-delete / drag-vertex / snap-ignore toggle, plus client-only hide
|
||||
// and restore. Cleared on exit OSM edit mode. Merges aren't undoable here
|
||||
// (they delete source rows; this ring buffer doesn't recreate them).
|
||||
// and restore. Persists across OSM-mode toggles within a session so users
|
||||
// can exit/re-enter without losing their last few edits. Merges aren't
|
||||
// undoable here (they delete source rows; this ring buffer doesn't
|
||||
// recreate them).
|
||||
type OsmRoadSnap = {
|
||||
id: number;
|
||||
name: string | null;
|
||||
@@ -594,6 +597,14 @@ function AppShell({
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(ROAD_OFFSET_KEY, String(roadOffsetMeters));
|
||||
}, [roadOffsetMeters]);
|
||||
// Per-road lane-offset overrides. The slider above sets the default; when
|
||||
// one or more roads are selected, the slider writes an override into this
|
||||
// map so each road's parallels can sit at its own width without dragging
|
||||
// the rest of the network with it. In-memory only — overrides reset across
|
||||
// reloads, but the default persists.
|
||||
const [roadOffsetById, setRoadOffsetById] = useState<Map<number, number>>(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const w = POPUP_WIDTH_PX[popupSize];
|
||||
@@ -1070,6 +1081,18 @@ function AppShell({
|
||||
.catch((err) => setStatus(`Unlink failed: ${err}`));
|
||||
return;
|
||||
}
|
||||
// 'O' / 'o' toggles OSM edit mode. Ignored while typing in inputs.
|
||||
if (e.key === "o" || e.key === "O") {
|
||||
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();
|
||||
toggleOsmEditModeRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Escape") return;
|
||||
if (lensActive) {
|
||||
e.preventDefault();
|
||||
@@ -1420,7 +1443,13 @@ function AppShell({
|
||||
|
||||
async function refreshOsmRoads() {
|
||||
const list = await invoke<
|
||||
Array<{ id: number; name: string | null; geojson: string; snap_ignored?: boolean }>
|
||||
Array<{
|
||||
id: number;
|
||||
name: string | null;
|
||||
geojson: string;
|
||||
snap_ignored?: boolean;
|
||||
modified?: boolean;
|
||||
}>
|
||||
>("get_osm_roads");
|
||||
let mnLat = Infinity;
|
||||
let mxLat = -Infinity;
|
||||
@@ -1455,6 +1484,7 @@ function AppShell({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
snap_ignored: r.snap_ignored === true,
|
||||
modified: r.modified === true,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1701,6 +1731,11 @@ function AppShell({
|
||||
setStatus("OSM edit mode: click a road to edit its vertices.");
|
||||
}
|
||||
}
|
||||
// Ref so the keyboard handler always sees the latest closure (otherwise
|
||||
// toggling via 'O' would capture stale layerVisibility / osmEditMode from
|
||||
// whichever render the keydown effect last re-ran on).
|
||||
const toggleOsmEditModeRef = useRef(toggleOsmEditMode);
|
||||
toggleOsmEditModeRef.current = toggleOsmEditMode;
|
||||
|
||||
function startDrawRoad() {
|
||||
setDrawRoadCoords([]);
|
||||
@@ -2150,7 +2185,7 @@ function AppShell({
|
||||
const ok = window.confirm(
|
||||
`Bulk-delete OSM road sections inside this lasso?\n\n` +
|
||||
`Roads fully inside are removed; roads partially inside are clipped at the lasso boundary — the inside portion is dropped, the outside portion(s) survive as separate roads. ` +
|
||||
`This is NOT covered by the OSM Undo (the ring buffer doesn't recreate deleted rows).`,
|
||||
`Covered by OSM Undo: the original rows are restored and the split-out rows are deleted on undo.`,
|
||||
);
|
||||
if (!ok) {
|
||||
setLassoMode(false);
|
||||
@@ -2162,11 +2197,23 @@ function AppShell({
|
||||
roads_deleted: number;
|
||||
roads_split: number;
|
||||
new_road_ids: number[];
|
||||
before_snapshots: OsmRoadSnap[];
|
||||
}>("delete_roads_in_lasso", { polygon })
|
||||
.then(async (r) => {
|
||||
setStatus(
|
||||
`Lasso roads: examined ${r.roads_examined}, fully deleted ${r.roads_deleted}, split ${r.roads_split} (→ ${r.new_road_ids.length} new sub-road(s)).`,
|
||||
);
|
||||
// Push to the OSM undo ring: restore_road_state upserts the
|
||||
// snapshots (re-inserts deleted rows by id) and the createdIds
|
||||
// path drops the split-out children before that runs.
|
||||
if (r.before_snapshots.length > 0 || r.new_road_ids.length > 0) {
|
||||
pushOsmUndo({
|
||||
kind: "restore-roads",
|
||||
snapshots: r.before_snapshots,
|
||||
createdIds: r.new_road_ids,
|
||||
label: "lasso road delete",
|
||||
});
|
||||
}
|
||||
// The currently-edited / selected / marked road(s) may have been
|
||||
// deleted or split — drop those UI references so the next click
|
||||
// doesn't act on a stale id.
|
||||
@@ -3276,6 +3323,7 @@ function AppShell({
|
||||
}}
|
||||
onUpdateRoad={handleUpdateRoad}
|
||||
roadOffsetMeters={roadOffsetMeters}
|
||||
roadOffsetById={roadOffsetById}
|
||||
drawRoadMode={drawRoadMode}
|
||||
drawRoadCoords={drawRoadCoords}
|
||||
onDrawRoadAddPoint={handleDrawRoadAddPoint}
|
||||
@@ -4903,23 +4951,70 @@ function AppShell({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{osmEditMode && (
|
||||
<label
|
||||
className="field"
|
||||
style={{ marginTop: 6, display: "block" }}
|
||||
>
|
||||
<span>Lane offset (m): {roadOffsetMeters.toFixed(1)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={30}
|
||||
step={0.1}
|
||||
value={roadOffsetMeters}
|
||||
onChange={(e) => setRoadOffsetMeters(parseFloat(e.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{osmEditMode && (() => {
|
||||
// Roads the slider should affect: editing road + multi-selected
|
||||
// set. Empty → slider edits the default for all roads.
|
||||
const sliderIds = new Set<number>(selectedRoadIds);
|
||||
if (editingRoadId !== null) sliderIds.add(editingRoadId);
|
||||
const hasSelection = sliderIds.size > 0;
|
||||
// Effective value to display. With a selection, prefer the first
|
||||
// selected road's override (falling back to default). Without one,
|
||||
// show the default.
|
||||
const firstId = hasSelection
|
||||
? (Array.from(sliderIds)[0] as number)
|
||||
: null;
|
||||
const effective =
|
||||
firstId !== null
|
||||
? (roadOffsetById.get(firstId) ?? roadOffsetMeters)
|
||||
: roadOffsetMeters;
|
||||
return (
|
||||
<label
|
||||
className="field"
|
||||
style={{ marginTop: 6, display: "block" }}
|
||||
>
|
||||
<span>
|
||||
Lane offset (m){hasSelection ? ` [${sliderIds.size} selected]` : " [default]"}
|
||||
: {effective.toFixed(1)}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={30}
|
||||
step={0.1}
|
||||
value={effective}
|
||||
onChange={(e) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
if (!hasSelection) {
|
||||
setRoadOffsetMeters(v);
|
||||
return;
|
||||
}
|
||||
setRoadOffsetById((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const id of sliderIds) next.set(id, v);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
{hasSelection && (
|
||||
<button
|
||||
className="primary-btn"
|
||||
style={{ marginTop: 4, background: "#7f8c8d", fontSize: 11 }}
|
||||
onClick={() => {
|
||||
setRoadOffsetById((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const id of sliderIds) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
title="Drop the per-road override on the selected road(s) so they follow the default again."
|
||||
>
|
||||
Reset selected to default ({roadOffsetMeters.toFixed(1)} m)
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})()}
|
||||
<button
|
||||
className="primary-btn"
|
||||
style={{ marginTop: 6, background: "#1abc9c" }}
|
||||
|
||||
Reference in New Issue
Block a user