osm undo option
This commit is contained in:
141
src/App.tsx
141
src/App.tsx
@@ -381,16 +381,19 @@ function AppShell({
|
|||||||
}, [editingRoadId, osmEditMode]);
|
}, [editingRoadId, osmEditMode]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!osmEditMode) {
|
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());
|
setSelectedRoadIds(new Set());
|
||||||
setHiddenRoadIds(new Set());
|
|
||||||
setOsmUndoStack([]);
|
|
||||||
}
|
}
|
||||||
}, [osmEditMode]);
|
}, [osmEditMode]);
|
||||||
|
|
||||||
// Per-session OSM edit undo: ring buffer (last 3) covering flip / simplify
|
// Per-session OSM edit undo: ring buffer (last 3) covering flip / simplify
|
||||||
// / vertex-delete / drag-vertex / snap-ignore toggle, plus client-only hide
|
// / vertex-delete / drag-vertex / snap-ignore toggle, plus client-only hide
|
||||||
// and restore. Cleared on exit OSM edit mode. Merges aren't undoable here
|
// and restore. Persists across OSM-mode toggles within a session so users
|
||||||
// (they delete source rows; this ring buffer doesn't recreate them).
|
// 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 = {
|
type OsmRoadSnap = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -594,6 +597,14 @@ function AppShell({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.localStorage.setItem(ROAD_OFFSET_KEY, String(roadOffsetMeters));
|
window.localStorage.setItem(ROAD_OFFSET_KEY, String(roadOffsetMeters));
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
const w = POPUP_WIDTH_PX[popupSize];
|
const w = POPUP_WIDTH_PX[popupSize];
|
||||||
@@ -1070,6 +1081,18 @@ function AppShell({
|
|||||||
.catch((err) => setStatus(`Unlink failed: ${err}`));
|
.catch((err) => setStatus(`Unlink failed: ${err}`));
|
||||||
return;
|
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 (e.key !== "Escape") return;
|
||||||
if (lensActive) {
|
if (lensActive) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1420,7 +1443,13 @@ function AppShell({
|
|||||||
|
|
||||||
async function refreshOsmRoads() {
|
async function refreshOsmRoads() {
|
||||||
const list = await invoke<
|
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");
|
>("get_osm_roads");
|
||||||
let mnLat = Infinity;
|
let mnLat = Infinity;
|
||||||
let mxLat = -Infinity;
|
let mxLat = -Infinity;
|
||||||
@@ -1455,6 +1484,7 @@ function AppShell({
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
snap_ignored: r.snap_ignored === true,
|
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.");
|
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() {
|
function startDrawRoad() {
|
||||||
setDrawRoadCoords([]);
|
setDrawRoadCoords([]);
|
||||||
@@ -2150,7 +2185,7 @@ function AppShell({
|
|||||||
const ok = window.confirm(
|
const ok = window.confirm(
|
||||||
`Bulk-delete OSM road sections inside this lasso?\n\n` +
|
`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. ` +
|
`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) {
|
if (!ok) {
|
||||||
setLassoMode(false);
|
setLassoMode(false);
|
||||||
@@ -2162,11 +2197,23 @@ function AppShell({
|
|||||||
roads_deleted: number;
|
roads_deleted: number;
|
||||||
roads_split: number;
|
roads_split: number;
|
||||||
new_road_ids: number[];
|
new_road_ids: number[];
|
||||||
|
before_snapshots: OsmRoadSnap[];
|
||||||
}>("delete_roads_in_lasso", { polygon })
|
}>("delete_roads_in_lasso", { polygon })
|
||||||
.then(async (r) => {
|
.then(async (r) => {
|
||||||
setStatus(
|
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)).`,
|
`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
|
// The currently-edited / selected / marked road(s) may have been
|
||||||
// deleted or split — drop those UI references so the next click
|
// deleted or split — drop those UI references so the next click
|
||||||
// doesn't act on a stale id.
|
// doesn't act on a stale id.
|
||||||
@@ -3276,6 +3323,7 @@ function AppShell({
|
|||||||
}}
|
}}
|
||||||
onUpdateRoad={handleUpdateRoad}
|
onUpdateRoad={handleUpdateRoad}
|
||||||
roadOffsetMeters={roadOffsetMeters}
|
roadOffsetMeters={roadOffsetMeters}
|
||||||
|
roadOffsetById={roadOffsetById}
|
||||||
drawRoadMode={drawRoadMode}
|
drawRoadMode={drawRoadMode}
|
||||||
drawRoadCoords={drawRoadCoords}
|
drawRoadCoords={drawRoadCoords}
|
||||||
onDrawRoadAddPoint={handleDrawRoadAddPoint}
|
onDrawRoadAddPoint={handleDrawRoadAddPoint}
|
||||||
@@ -4903,23 +4951,70 @@ function AppShell({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{osmEditMode && (
|
{osmEditMode && (() => {
|
||||||
<label
|
// Roads the slider should affect: editing road + multi-selected
|
||||||
className="field"
|
// set. Empty → slider edits the default for all roads.
|
||||||
style={{ marginTop: 6, display: "block" }}
|
const sliderIds = new Set<number>(selectedRoadIds);
|
||||||
>
|
if (editingRoadId !== null) sliderIds.add(editingRoadId);
|
||||||
<span>Lane offset (m): {roadOffsetMeters.toFixed(1)}</span>
|
const hasSelection = sliderIds.size > 0;
|
||||||
<input
|
// Effective value to display. With a selection, prefer the first
|
||||||
type="range"
|
// selected road's override (falling back to default). Without one,
|
||||||
min={0.1}
|
// show the default.
|
||||||
max={30}
|
const firstId = hasSelection
|
||||||
step={0.1}
|
? (Array.from(sliderIds)[0] as number)
|
||||||
value={roadOffsetMeters}
|
: null;
|
||||||
onChange={(e) => setRoadOffsetMeters(parseFloat(e.target.value))}
|
const effective =
|
||||||
style={{ width: "100%" }}
|
firstId !== null
|
||||||
/>
|
? (roadOffsetById.get(firstId) ?? roadOffsetMeters)
|
||||||
</label>
|
: 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
|
<button
|
||||||
className="primary-btn"
|
className="primary-btn"
|
||||||
style={{ marginTop: 6, background: "#1abc9c" }}
|
style={{ marginTop: 6, background: "#1abc9c" }}
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ export type OsmRoadFC = {
|
|||||||
features: Array<{
|
features: Array<{
|
||||||
type: "Feature";
|
type: "Feature";
|
||||||
geometry: unknown;
|
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;
|
multiSelectActive?: boolean;
|
||||||
onUpdateRoad?: (id: number, coords: Array<[number, number]>) => void;
|
onUpdateRoad?: (id: number, coords: Array<[number, number]>) => void;
|
||||||
roadOffsetMeters?: number;
|
roadOffsetMeters?: number;
|
||||||
|
roadOffsetById?: Map<number, number>;
|
||||||
drawRoadMode?: boolean;
|
drawRoadMode?: boolean;
|
||||||
drawRoadCoords?: Array<[number, number]>;
|
drawRoadCoords?: Array<[number, number]>;
|
||||||
onDrawRoadAddPoint?: (lat: number, lng: number) => void;
|
onDrawRoadAddPoint?: (lat: number, lng: number) => void;
|
||||||
@@ -152,6 +158,7 @@ export function MapView({
|
|||||||
multiSelectActive = false,
|
multiSelectActive = false,
|
||||||
onUpdateRoad,
|
onUpdateRoad,
|
||||||
roadOffsetMeters = 4,
|
roadOffsetMeters = 4,
|
||||||
|
roadOffsetById,
|
||||||
drawRoadMode = false,
|
drawRoadMode = false,
|
||||||
drawRoadCoords = [],
|
drawRoadCoords = [],
|
||||||
onDrawRoadAddPoint,
|
onDrawRoadAddPoint,
|
||||||
@@ -237,10 +244,11 @@ export function MapView({
|
|||||||
}
|
}
|
||||||
| null;
|
| null;
|
||||||
if (!info || !info.object) return;
|
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 (
|
if (
|
||||||
osmEditModeRef.current &&
|
osmEditModeRef.current &&
|
||||||
info.layer?.id === "osm-roads" &&
|
(info.layer?.id === "osm-roads" || info.layer?.id === "osm-roads-pick") &&
|
||||||
onRoadRightClickRef.current
|
onRoadRightClickRef.current
|
||||||
) {
|
) {
|
||||||
const f = info.object as { properties?: { id?: number } };
|
const f = info.object as { properties?: { id?: number } };
|
||||||
@@ -319,7 +327,10 @@ export function MapView({
|
|||||||
onPlaceAtRef.current?.(lat, lng);
|
onPlaceAtRef.current?.(lat, lng);
|
||||||
return;
|
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
|
const f = info.object as
|
||||||
| {
|
| {
|
||||||
properties?: { id?: number };
|
properties?: { id?: number };
|
||||||
@@ -592,6 +603,26 @@ export function MapView({
|
|||||||
pickable: false,
|
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(
|
layers.push(
|
||||||
new GeoJsonLayer({
|
new GeoJsonLayer({
|
||||||
id: "osm-roads",
|
id: "osm-roads",
|
||||||
@@ -599,12 +630,21 @@ export function MapView({
|
|||||||
stroked: true,
|
stroked: true,
|
||||||
filled: false,
|
filled: false,
|
||||||
getLineColor: (f: unknown) => {
|
getLineColor: (f: unknown) => {
|
||||||
const p = (f as { properties?: { id?: number; snap_ignored?: boolean } })
|
const p = (f as {
|
||||||
.properties;
|
properties?: {
|
||||||
|
id?: number;
|
||||||
|
snap_ignored?: boolean;
|
||||||
|
modified?: boolean;
|
||||||
|
};
|
||||||
|
}).properties;
|
||||||
if (p && selectedRoadIds && typeof p.id === "number" && selectedRoadIds.has(p.id)) {
|
if (p && selectedRoadIds && typeof p.id === "number" && selectedRoadIds.has(p.id)) {
|
||||||
return [255, 196, 0, 255];
|
return [255, 196, 0, 255];
|
||||||
}
|
}
|
||||||
if (p?.snap_ignored) return [120, 120, 120, 180];
|
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];
|
return [0, 229, 255, 255];
|
||||||
},
|
},
|
||||||
getLineWidth: (f: unknown) => {
|
getLineWidth: (f: unknown) => {
|
||||||
@@ -615,14 +655,16 @@ export function MapView({
|
|||||||
return 3;
|
return 3;
|
||||||
},
|
},
|
||||||
lineWidthUnits: "pixels",
|
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: {
|
updateTriggers: {
|
||||||
getLineColor: [selectedRoadIds],
|
getLineColor: [selectedRoadIds],
|
||||||
getLineWidth: [selectedRoadIds],
|
getLineWidth: [selectedRoadIds],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (osmEditMode && roadOffsetMeters > 0) {
|
if (osmEditMode && (roadOffsetMeters > 0 || (roadOffsetById && roadOffsetById.size > 0))) {
|
||||||
const haveTrack = metadataTrack.length >= 2;
|
const haveTrack = metadataTrack.length >= 2;
|
||||||
const headingAt = (lng: number, lat: number): [number, number] => {
|
const headingAt = (lng: number, lat: number): [number, number] => {
|
||||||
if (!haveTrack) return [0, 0];
|
if (!haveTrack) return [0, 0];
|
||||||
@@ -648,6 +690,15 @@ export function MapView({
|
|||||||
| { type: string; coordinates: Array<[number, number]> }
|
| { type: string; coordinates: Array<[number, number]> }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!g || g.type !== "LineString" || g.coordinates.length < 2) continue;
|
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 lat0 = g.coordinates[0][1];
|
||||||
const mPerLng = 111320 * Math.cos((lat0 * Math.PI) / 180) || 1;
|
const mPerLng = 111320 * Math.cos((lat0 * Math.PI) / 180) || 1;
|
||||||
const mPerLat = 111320;
|
const mPerLat = 111320;
|
||||||
@@ -674,8 +725,8 @@ export function MapView({
|
|||||||
const len = Math.hypot(dx, dy) || 1;
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
const px = -dy / len;
|
const px = -dy / len;
|
||||||
const py = dx / len;
|
const py = dx / len;
|
||||||
const offX = (side * px * roadOffsetMeters) / mPerLng;
|
const offX = (side * px * offsetForThis) / mPerLng;
|
||||||
const offY = (side * py * roadOffsetMeters) / mPerLat;
|
const offY = (side * py * offsetForThis) / mPerLat;
|
||||||
out.push([
|
out.push([
|
||||||
g.coordinates[i][0] + offX,
|
g.coordinates[i][0] + offX,
|
||||||
g.coordinates[i][1] + offY,
|
g.coordinates[i][1] + offY,
|
||||||
@@ -1528,6 +1579,7 @@ export function MapView({
|
|||||||
osmEditMode,
|
osmEditMode,
|
||||||
editingRoadId,
|
editingRoadId,
|
||||||
roadOffsetMeters,
|
roadOffsetMeters,
|
||||||
|
roadOffsetById,
|
||||||
drawRoadMode,
|
drawRoadMode,
|
||||||
drawRoadCoords,
|
drawRoadCoords,
|
||||||
assets,
|
assets,
|
||||||
|
|||||||
Reference in New Issue
Block a user