linear bug and osm new features
This commit is contained in:
178
src/App.tsx
178
src/App.tsx
@@ -400,7 +400,15 @@ function AppShell({
|
||||
snap_ignored: number;
|
||||
};
|
||||
type OsmUndoEntry =
|
||||
| { kind: "restore-roads"; snapshots: OsmRoadSnap[]; label: string }
|
||||
| {
|
||||
kind: "restore-roads";
|
||||
snapshots: OsmRoadSnap[];
|
||||
// Rows that were *created* by the operation (e.g. the right-half row
|
||||
// from a section split). Undo deletes them before restoring snapshots,
|
||||
// so the original geometry is the only survivor.
|
||||
createdIds?: number[];
|
||||
label: string;
|
||||
}
|
||||
| { kind: "unhide"; ids: number[]; label: string }
|
||||
| { kind: "rehide"; ids: number[]; label: string };
|
||||
const [osmUndoStack, setOsmUndoStack] = useState<OsmUndoEntry[]>([]);
|
||||
@@ -423,12 +431,41 @@ function AppShell({
|
||||
console.warn("osm_road_snapshot failed", e);
|
||||
}
|
||||
}
|
||||
// For ops that mutate AND create new rows (delete_road_section split), the
|
||||
// caller should snapshot first, run the op, then call this with the ids
|
||||
// that were freshly inserted so undo can drop them on the way back.
|
||||
function attachCreatedIdsToLastUndo(createdIds: number[]) {
|
||||
if (createdIds.length === 0) return;
|
||||
setOsmUndoStack((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
const last = prev[prev.length - 1];
|
||||
if (last.kind !== "restore-roads") return prev;
|
||||
const next = prev.slice();
|
||||
next[next.length - 1] = {
|
||||
...last,
|
||||
createdIds: [...(last.createdIds ?? []), ...createdIds],
|
||||
};
|
||||
return next;
|
||||
});
|
||||
}
|
||||
async function performOsmUndo() {
|
||||
if (osmUndoStack.length === 0) return;
|
||||
const last = osmUndoStack[osmUndoStack.length - 1];
|
||||
setOsmUndoStack((prev) => prev.slice(0, -1));
|
||||
try {
|
||||
if (last.kind === "restore-roads") {
|
||||
// Delete any rows that the operation created before restoring the
|
||||
// original geometry — otherwise a split-section undo leaves a stale
|
||||
// duplicate alongside the resurrected original.
|
||||
if (last.createdIds && last.createdIds.length > 0) {
|
||||
for (const cid of last.createdIds) {
|
||||
try {
|
||||
await invoke("delete_osm_road", { id: cid });
|
||||
} catch (e) {
|
||||
console.warn(`undo: delete_osm_road(${cid}) failed`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
const n = await invoke<number>("restore_road_state", {
|
||||
snapshots: last.snapshots,
|
||||
});
|
||||
@@ -2069,6 +2106,82 @@ function AppShell({
|
||||
Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(s));
|
||||
};
|
||||
// OSM-edit-mode lasso: convert shape → polygon and bulk-delete the road
|
||||
// sections that fall inside it. Skips the asset-bulk-action flow below.
|
||||
if (osmEditMode) {
|
||||
const m_per_lat = 111_320;
|
||||
let polygon: Array<[number, number]> = [];
|
||||
if (shape === "circle") {
|
||||
const c = points[0];
|
||||
const edge = points[1];
|
||||
const radius_m = haversine(c, edge);
|
||||
if (radius_m < 1) {
|
||||
setStatus("Lasso too small.");
|
||||
return;
|
||||
}
|
||||
const m_per_lng = 111_320 * Math.cos(toRad(c[1]));
|
||||
const N = 64;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const a = (i / N) * Math.PI * 2;
|
||||
polygon.push([
|
||||
c[0] + (radius_m * Math.cos(a)) / m_per_lng,
|
||||
c[1] + (radius_m * Math.sin(a)) / m_per_lat,
|
||||
]);
|
||||
}
|
||||
} else if (shape === "rect") {
|
||||
const [a, b] = points;
|
||||
const minLng = Math.min(a[0], b[0]);
|
||||
const maxLng = Math.max(a[0], b[0]);
|
||||
const minLat = Math.min(a[1], b[1]);
|
||||
const maxLat = Math.max(a[1], b[1]);
|
||||
polygon = [
|
||||
[minLng, minLat],
|
||||
[maxLng, minLat],
|
||||
[maxLng, maxLat],
|
||||
[minLng, maxLat],
|
||||
];
|
||||
} else {
|
||||
if (points.length < 3) {
|
||||
setStatus("Need at least 3 polygon vertices.");
|
||||
return;
|
||||
}
|
||||
polygon = points.map((p) => [p[0], p[1]] as [number, number]);
|
||||
}
|
||||
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).`,
|
||||
);
|
||||
if (!ok) {
|
||||
setLassoMode(false);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
invoke<{
|
||||
roads_examined: number;
|
||||
roads_deleted: number;
|
||||
roads_split: number;
|
||||
new_road_ids: number[];
|
||||
}>("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)).`,
|
||||
);
|
||||
// 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.
|
||||
setEditingRoadId(null);
|
||||
setMarkedVertices(new Set());
|
||||
setSelectedRoadIds(new Set());
|
||||
await refreshOsmRoads();
|
||||
})
|
||||
.catch((e) => setStatus(`Bulk road delete failed: ${e}`))
|
||||
.finally(() => {
|
||||
setBusy(false);
|
||||
setLassoMode(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const ids: number[] = [];
|
||||
let label = "";
|
||||
if (shape === "circle") {
|
||||
@@ -4667,6 +4780,56 @@ function AppShell({
|
||||
>
|
||||
Simplify this road
|
||||
</button>
|
||||
<button
|
||||
className="primary-btn"
|
||||
style={{
|
||||
marginTop: 4,
|
||||
width: "100%",
|
||||
background: "#c0392b",
|
||||
fontSize: 11,
|
||||
}}
|
||||
disabled={busy || markedVertices.size < 2}
|
||||
onClick={async () => {
|
||||
if (editingRoadId === null) return;
|
||||
const idx = Array.from(markedVertices).sort((a, b) => a - b);
|
||||
if (idx.length < 2) return;
|
||||
const ok = window.confirm(
|
||||
`Delete the road section between vertex #${idx[0]} and vertex #${idx[idx.length - 1]}?\n\n` +
|
||||
"If both halves keep ≥2 vertices the road splits in two; otherwise the original road is shrunk.",
|
||||
);
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await snapshotRoadsForUndo([editingRoadId], "delete section");
|
||||
const origId = editingRoadId;
|
||||
const ids = await invoke<number[]>("delete_road_section", {
|
||||
id: origId,
|
||||
indices: idx,
|
||||
});
|
||||
// Anything other than the original id was freshly inserted
|
||||
// by the split — attach those to the snapshot so undo
|
||||
// deletes them before restoring the original geometry.
|
||||
const createdIds = ids.filter((i) => i !== origId);
|
||||
if (createdIds.length > 0) {
|
||||
attachCreatedIdsToLastUndo(createdIds);
|
||||
}
|
||||
setMarkedVertices(new Set());
|
||||
setStatus(
|
||||
ids.length === 1
|
||||
? `Section deleted; road #${ids[0]} kept.`
|
||||
: `Section deleted; road split into #${ids[0]} and #${ids[1]}.`,
|
||||
);
|
||||
await refreshOsmRoads();
|
||||
} catch (e) {
|
||||
setStatus(`Delete section failed: ${e}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
title="Drop the marked vertex range from this road. With 2 marks: deletes everything strictly between them. Splits the road into two if both halves still have ≥2 vertices."
|
||||
>
|
||||
Delete section between marked
|
||||
</button>
|
||||
</div>
|
||||
{focusedVideo === null && (
|
||||
<button
|
||||
@@ -4852,12 +5015,17 @@ function AppShell({
|
||||
setLassoSelected(null);
|
||||
setLassoPolygonPoints(null);
|
||||
setLassoMode((v) => !v);
|
||||
const turningOn = !lassoMode;
|
||||
const targetHint = osmEditMode
|
||||
? "OSM edit mode is on — lasso will bulk-delete road sections, NOT assets."
|
||||
: "";
|
||||
setStatus(
|
||||
lassoMode
|
||||
!turningOn
|
||||
? "Lasso off."
|
||||
: lassoShape === "polygon"
|
||||
? "Lasso on: click to add polygon vertices, double-click to finish."
|
||||
: "Lasso on: drag to select.",
|
||||
: (lassoShape === "polygon"
|
||||
? "Lasso on: click to add polygon vertices, double-click to finish."
|
||||
: "Lasso on: drag to select.") +
|
||||
(targetHint ? ` ${targetHint}` : ""),
|
||||
);
|
||||
}}
|
||||
disabled={busy}
|
||||
|
||||
140
src/MapView.tsx
140
src/MapView.tsx
@@ -192,6 +192,8 @@ export function MapView({
|
||||
osmEditModeRef.current = osmEditMode;
|
||||
const onPickRoadRef = useRef(onPickRoad);
|
||||
onPickRoadRef.current = onPickRoad;
|
||||
const onUpdateRoadRef = useRef(onUpdateRoad);
|
||||
onUpdateRoadRef.current = onUpdateRoad;
|
||||
const onRoadRightClickRef = useRef(onRoadRightClick);
|
||||
onRoadRightClickRef.current = onRoadRightClick;
|
||||
const onCursorWorldRef = useRef(onCursorWorld);
|
||||
@@ -285,11 +287,28 @@ export function MapView({
|
||||
const overlay = new MapboxOverlay({
|
||||
layers: [],
|
||||
interleaved: false,
|
||||
onClick: (info: {
|
||||
object?: unknown;
|
||||
coordinate?: number[];
|
||||
layer?: { id: string };
|
||||
// Show a pointer when hovering a road in OSM edit mode so it's clear
|
||||
// they're clickable. The default canvas cursor (set in the cursor
|
||||
// effect below) wins when no pickable object is under the mouse.
|
||||
getCursor: ({
|
||||
isHovering,
|
||||
isDragging,
|
||||
}: {
|
||||
isHovering: boolean;
|
||||
isDragging: boolean;
|
||||
}) => {
|
||||
if (isDragging) return "grabbing";
|
||||
if (isHovering && osmEditModeRef.current) return "pointer";
|
||||
return "";
|
||||
},
|
||||
onClick: (
|
||||
info: {
|
||||
object?: unknown;
|
||||
coordinate?: number[];
|
||||
layer?: { id: string };
|
||||
},
|
||||
event?: { srcEvent?: { shiftKey?: boolean } },
|
||||
) => {
|
||||
if (drawRoadModeRef.current && info.coordinate) {
|
||||
const [lng, lat] = info.coordinate;
|
||||
onDrawRoadAddPointRef.current?.(lat, lng);
|
||||
@@ -302,12 +321,62 @@ export function MapView({
|
||||
}
|
||||
if (osmEditModeRef.current && info.layer?.id === "osm-roads") {
|
||||
const f = info.object as
|
||||
| { properties?: { id?: number } }
|
||||
| {
|
||||
properties?: { id?: number };
|
||||
geometry?: {
|
||||
type?: string;
|
||||
coordinates?: Array<[number, number]>;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
const rid = f?.properties?.id;
|
||||
if (typeof rid === "number") {
|
||||
onPickRoadRef.current?.(rid);
|
||||
if (typeof rid !== "number") return;
|
||||
// Shift+click on a road inserts a vertex at the click location,
|
||||
// snapped to the nearest edge. Mirrors the ghost-midpoint UX but
|
||||
// works anywhere along the polyline — saves the user from zooming
|
||||
// in to find the right midpoint.
|
||||
const shift = event?.srcEvent?.shiftKey === true;
|
||||
if (shift && info.coordinate && f?.geometry?.coordinates) {
|
||||
const [clng, clat] = info.coordinate;
|
||||
const coords = f.geometry.coordinates;
|
||||
// Find nearest edge (project click onto each segment, clamped).
|
||||
let bestI = -1;
|
||||
let bestD2 = Infinity;
|
||||
let bestPt: [number, number] = [clng, clat];
|
||||
for (let i = 0; i < coords.length - 1; i++) {
|
||||
const a = coords[i];
|
||||
const b = coords[i + 1];
|
||||
const dx = b[0] - a[0];
|
||||
const dy = b[1] - a[1];
|
||||
const len2 = dx * dx + dy * dy;
|
||||
const t =
|
||||
len2 > 0
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
((clng - a[0]) * dx + (clat - a[1]) * dy) / len2,
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
const px = a[0] + t * dx;
|
||||
const py = a[1] + t * dy;
|
||||
const d2 =
|
||||
(clng - px) * (clng - px) + (clat - py) * (clat - py);
|
||||
if (d2 < bestD2) {
|
||||
bestD2 = d2;
|
||||
bestI = i;
|
||||
bestPt = [px, py];
|
||||
}
|
||||
}
|
||||
if (bestI >= 0) {
|
||||
const next = coords.slice() as Array<[number, number]>;
|
||||
next.splice(bestI + 1, 0, bestPt);
|
||||
onUpdateRoadRef.current?.(rid, next);
|
||||
return;
|
||||
}
|
||||
}
|
||||
onPickRoadRef.current?.(rid);
|
||||
return;
|
||||
}
|
||||
if (osmEditModeRef.current) {
|
||||
@@ -1526,6 +1595,54 @@ export function MapView({
|
||||
// fetchPriority hint (Chrome / WebKit) — prioritise the visible popup image.
|
||||
(img as HTMLImageElement & { fetchPriority?: string }).fetchPriority =
|
||||
"high";
|
||||
// The image uses object-fit:cover so the container's aspect is fixed.
|
||||
// Bias the visible crop toward the side the asset is on so the user
|
||||
// sees the relevant edge instead of the centre being cropped.
|
||||
const sideKeyImg = (selectedAsset.side ?? "").toLowerCase();
|
||||
img.style.objectPosition =
|
||||
sideKeyImg === "left" || sideKeyImg === "lhs"
|
||||
? "left center"
|
||||
: sideKeyImg === "right" || sideKeyImg === "rhs"
|
||||
? "right center"
|
||||
: "center center";
|
||||
img.style.cursor = "ew-resize";
|
||||
img.title = "Drag horizontally to pan the cropped window";
|
||||
// Click-and-drag updates object-position-x so the user can pan to any
|
||||
// part of the source image — useful when the side-aware default still
|
||||
// hides what they want to see. Pointer capture keeps the gesture
|
||||
// confined to this element, so popup teardown removes the handlers
|
||||
// automatically.
|
||||
const sideDefaultPct =
|
||||
sideKeyImg === "left" || sideKeyImg === "lhs"
|
||||
? 0
|
||||
: sideKeyImg === "right" || sideKeyImg === "rhs"
|
||||
? 100
|
||||
: 50;
|
||||
let curPct = sideDefaultPct;
|
||||
let dragX0: number | null = null;
|
||||
let dragPos0 = sideDefaultPct;
|
||||
img.addEventListener("pointerdown", (ev) => {
|
||||
ev.preventDefault();
|
||||
dragX0 = ev.clientX;
|
||||
dragPos0 = curPct;
|
||||
img.setPointerCapture(ev.pointerId);
|
||||
});
|
||||
img.addEventListener("pointermove", (ev) => {
|
||||
if (dragX0 === null) return;
|
||||
const w = img.clientWidth || 1;
|
||||
const dx = ev.clientX - dragX0;
|
||||
const next = Math.max(0, Math.min(100, dragPos0 - (dx / w) * 100));
|
||||
curPct = next;
|
||||
img.style.objectPosition = `${next}% center`;
|
||||
});
|
||||
const endDrag = (ev: PointerEvent) => {
|
||||
dragX0 = null;
|
||||
if (img.hasPointerCapture(ev.pointerId)) {
|
||||
img.releasePointerCapture(ev.pointerId);
|
||||
}
|
||||
};
|
||||
img.addEventListener("pointerup", endDrag);
|
||||
img.addEventListener("pointercancel", endDrag);
|
||||
grid.appendChild(img);
|
||||
root.appendChild(grid);
|
||||
|
||||
@@ -1626,6 +1743,15 @@ export function MapView({
|
||||
const id = selectedAsset.id;
|
||||
for (const v of verts) {
|
||||
if (v.lat === null || v.lng === null) continue;
|
||||
// Range assets without a real middle frame have a synthesized midpoint
|
||||
// (start+end)/2 stored in lat/lng so the line still renders, but no
|
||||
// image_path2. Hide the M pin in that case so users see only S and E.
|
||||
if (
|
||||
selectedAsset.asset_type === "range" &&
|
||||
v.kind === "mid" &&
|
||||
!selectedAsset.image_path2
|
||||
)
|
||||
continue;
|
||||
const el = document.createElement("div");
|
||||
const pin = document.createElement("div");
|
||||
pin.className = `vertex-pin v-${v.kind}`;
|
||||
|
||||
Reference in New Issue
Block a user