osm undo option
This commit is contained in:
117
src/App.tsx
117
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 && (
|
||||
{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): {roadOffsetMeters.toFixed(1)}</span>
|
||||
<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={roadOffsetMeters}
|
||||
onChange={(e) => setRoadOffsetMeters(parseFloat(e.target.value))}
|
||||
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%" }}
|
||||
/>
|
||||
</label>
|
||||
{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" }}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user