osm undo option

This commit is contained in:
2026-05-14 16:11:09 +05:30
parent 0336dacf4b
commit 05187dd35a
2 changed files with 180 additions and 33 deletions

View File

@@ -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" }}

View File

@@ -27,7 +27,12 @@ export type OsmRoadFC = {
features: Array<{
type: "Feature";
geometry: unknown;
properties: { id: number; name: string | null; snap_ignored?: boolean };
properties: {
id: number;
name: string | null;
snap_ignored?: boolean;
modified?: boolean;
};
}>;
};
@@ -81,6 +86,7 @@ type Props = {
multiSelectActive?: boolean;
onUpdateRoad?: (id: number, coords: Array<[number, number]>) => void;
roadOffsetMeters?: number;
roadOffsetById?: Map<number, number>;
drawRoadMode?: boolean;
drawRoadCoords?: Array<[number, number]>;
onDrawRoadAddPoint?: (lat: number, lng: number) => void;
@@ -152,6 +158,7 @@ export function MapView({
multiSelectActive = false,
onUpdateRoad,
roadOffsetMeters = 4,
roadOffsetById,
drawRoadMode = false,
drawRoadCoords = [],
onDrawRoadAddPoint,
@@ -237,10 +244,11 @@ export function MapView({
}
| null;
if (!info || !info.object) return;
// Road right-click takes priority when in OSM edit mode.
// Road right-click takes priority when in OSM edit mode. The wide
// transparent osm-roads-pick layer is what actually gets picked now.
if (
osmEditModeRef.current &&
info.layer?.id === "osm-roads" &&
(info.layer?.id === "osm-roads" || info.layer?.id === "osm-roads-pick") &&
onRoadRightClickRef.current
) {
const f = info.object as { properties?: { id?: number } };
@@ -319,7 +327,10 @@ export function MapView({
onPlaceAtRef.current?.(lat, lng);
return;
}
if (osmEditModeRef.current && info.layer?.id === "osm-roads") {
if (
osmEditModeRef.current &&
(info.layer?.id === "osm-roads" || info.layer?.id === "osm-roads-pick")
) {
const f = info.object as
| {
properties?: { id?: number };
@@ -592,6 +603,26 @@ export function MapView({
pickable: false,
}),
);
// Wide transparent pick-target sitting just behind the visible roads.
// The visible line is 3-5 px which is hard to click; the deck.gl picker
// checks against rendered width, so a 16-px see-through line gives the
// user a ~2-3 cell hit window without changing how roads look. Only
// turned on in OSM edit mode so non-edit clicks still fall through to
// assets underneath.
if (osmEditMode) {
layers.push(
new GeoJsonLayer({
id: "osm-roads-pick",
data: visibleOsm,
stroked: true,
filled: false,
getLineColor: [0, 0, 0, 0],
getLineWidth: 16,
lineWidthUnits: "pixels",
pickable: true,
}),
);
}
layers.push(
new GeoJsonLayer({
id: "osm-roads",
@@ -599,12 +630,21 @@ export function MapView({
stroked: true,
filled: false,
getLineColor: (f: unknown) => {
const p = (f as { properties?: { id?: number; snap_ignored?: boolean } })
.properties;
const p = (f as {
properties?: {
id?: number;
snap_ignored?: boolean;
modified?: boolean;
};
}).properties;
if (p && selectedRoadIds && typeof p.id === "number" && selectedRoadIds.has(p.id)) {
return [255, 196, 0, 255];
}
if (p?.snap_ignored) return [120, 120, 120, 180];
// Modified roads in green so users can see what they've touched
// at a glance. Selection (yellow) and snap-ignored (grey) still
// win when they apply.
if (p?.modified) return [46, 204, 113, 255];
return [0, 229, 255, 255];
},
getLineWidth: (f: unknown) => {
@@ -615,14 +655,16 @@ export function MapView({
return 3;
},
lineWidthUnits: "pixels",
pickable: osmEditMode,
// Visible layer no longer picks — the wider osm-roads-pick layer
// does. Keeps hit area consistent regardless of zoom.
pickable: false,
updateTriggers: {
getLineColor: [selectedRoadIds],
getLineWidth: [selectedRoadIds],
},
}),
);
if (osmEditMode && roadOffsetMeters > 0) {
if (osmEditMode && (roadOffsetMeters > 0 || (roadOffsetById && roadOffsetById.size > 0))) {
const haveTrack = metadataTrack.length >= 2;
const headingAt = (lng: number, lat: number): [number, number] => {
if (!haveTrack) return [0, 0];
@@ -648,6 +690,15 @@ export function MapView({
| { type: string; coordinates: Array<[number, number]> }
| undefined;
if (!g || g.type !== "LineString" || g.coordinates.length < 2) continue;
// Per-road override falls back to the global default. Skip drawing
// when both resolve to ≤ 0 so a "no parallels" preference can be set
// per road by overriding to zero.
const featId = (f.properties as { id?: number } | undefined)?.id;
const offsetForThis =
typeof featId === "number" && roadOffsetById?.get(featId) !== undefined
? (roadOffsetById.get(featId) as number)
: roadOffsetMeters;
if (offsetForThis <= 0) continue;
const lat0 = g.coordinates[0][1];
const mPerLng = 111320 * Math.cos((lat0 * Math.PI) / 180) || 1;
const mPerLat = 111320;
@@ -674,8 +725,8 @@ export function MapView({
const len = Math.hypot(dx, dy) || 1;
const px = -dy / len;
const py = dx / len;
const offX = (side * px * roadOffsetMeters) / mPerLng;
const offY = (side * py * roadOffsetMeters) / mPerLat;
const offX = (side * px * offsetForThis) / mPerLng;
const offY = (side * py * offsetForThis) / mPerLat;
out.push([
g.coordinates[i][0] + offX,
g.coordinates[i][1] + offY,
@@ -1528,6 +1579,7 @@ export function MapView({
osmEditMode,
editingRoadId,
roadOffsetMeters,
roadOffsetById,
drawRoadMode,
drawRoadCoords,
assets,