osm merge, optmisation

This commit is contained in:
2026-05-02 16:44:03 +05:30
parent 2139a908e6
commit ffb27eb17f
9 changed files with 2996 additions and 298 deletions

View File

@@ -104,7 +104,7 @@ Coordinates are `[lat, lng]`; the importer swaps to `[lng, lat]` internally. Gar
- Non-finite or out-of-range lat/lng. - Non-finite or out-of-range lat/lng.
- Jumps > 5 km between consecutive points. - Jumps > 5 km between consecutive points.
Stored only in memory (cleared by the **Loaded data → Clear** button or app restart). **Persisted in the SQLite database** (`video_metadata` table, keyed by `video_name`). Once imported, the polylines hydrate automatically on every app start — you don't have to re-pick the file each session. Cleared by the **Loaded data → Clear** button on the Per-video metadata row, or by **Reset DB** (which wipes the whole database).
### Metadata polyline (legacy single-track) ### Metadata polyline (legacy single-track)
Single-video legacy format used by the old FastAPI pipeline. Single-video legacy format used by the old FastAPI pipeline.

154
README.md
View File

@@ -12,7 +12,7 @@ Tauri 2 desktop app for reviewing, correcting and exporting fixed-asset / range-
- **Range assets (JSON)** — linear assets with start / mid / end (e.g., crash barriers). - **Range assets (JSON)** — linear assets with start / mid / end (e.g., crash barriers).
- **KML scope polygon** — defines which assets are "in scope". Points outside the polygon are hidden by default. - **KML scope polygon** — defines which assets are "in scope". Points outside the polygon are hidden by default.
- **OSM roads (GeoJSON)** — imported road network for snapping / direction lookups. - **OSM roads (GeoJSON)** — imported road network for snapping / direction lookups.
- **Metadata polyline (KML)** — vehicle GPS track from the source video; takes priority over OSM for direction inference. - **Per-video metadata (JSON)** — vehicle GPS track per video; takes priority over OSM for direction inference. **Persists in the SQLite database**, so you only pick the file once — every subsequent app start hydrates it automatically. Cleared only by the **Clear** button on the Per-video metadata row, or by **Reset DB**.
3. The map auto-centers on the first imported asset. Pan / zoom triggers viewport-bounded fetches (debounced) to keep the deck.gl rendering snappy. 3. The map auto-centers on the first imported asset. Pan / zoom triggers viewport-bounded fetches (debounced) to keep the deck.gl rendering snappy.
--- ---
@@ -51,6 +51,23 @@ Tauri 2 desktop app for reviewing, correcting and exporting fixed-asset / range-
--- ---
## Single-video focus mode
When you want to inspect / clean one video's worth of data in isolation:
- **Tap that video's metadata polyline on the map**, or
- **Leave exactly one video checked in the Filters panel**.
Either path triggers focus mode:
- Other videos' metadata polylines are hidden — only the focused track renders.
- OSM roads are spatially filtered to those within ~75 m of the focused track (cheap spatial-hash buffer; recomputed on each switch).
- Clicking the metadata line again, or unchecking the lone video filter, restores the full view.
Focus mode also unlocks the bulk OSM road tools (multi-select / hide / merge — see **OSM road editing**). Selection and hidden state are session-scoped: they reset whenever you exit OSM edit mode or change the focused video.
---
## Cross-side / cross-video pair linking ## Cross-side / cross-video pair linking
Common situation: one physical asset (e.g., a median streetlight) is captured in two videos (LHS and RHS) and shows up as two near-duplicate points. Pair linking marks them as the same physical thing without deleting either. Common situation: one physical asset (e.g., a median streetlight) is captured in two videos (LHS and RHS) and shows up as two near-duplicate points. Pair linking marks them as the same physical thing without deleting either.
@@ -97,45 +114,70 @@ Range assets (start / mid / end) translate as a rigid body — all three vertice
## OSM road editing ## OSM road editing
### Modes ### Modes
- **OSM tools…** modal — generate roads for the visible bbox (Overpass), import existing GeoJSON, export current roads, prune small road classes. - **OSM tools…** modal — generate roads for the visible bbox or for a buffer around imported assets (Overpass), import existing GeoJSON, export current roads, prune small road classes.
- **Edit OSM roads** button — toggles OSM edit mode. While on, every other layer is dimmed and only OSM roads are interactive. - **Edit OSM roads** button — toggles OSM edit mode. While on, every other layer is dimmed and only OSM roads are interactive.
- **Magnifier** button — cursor-following circular lens that zooms +3 levels around the cursor; renders satellite + OSM cyan + dashed-green metadata + red asset dots. Toggle on, hover anywhere, then ESC or click again to exit.
- **Lane offset (m)** — controls the parallel offset used by snap-to-road. Range 0.130 m. - **Lane offset (m)** — controls the parallel offset used by snap-to-road. Range 0.130 m.
### Picking a road to edit ### Two click models
1. Turn on **Edit OSM roads** (right panel → Data section).
2. Zoom in to **z ≥ 14** — vertex handles only render at that zoom or deeper, to keep the map responsive.
3. Click any road. Its yellow `•` vertex pins appear; other roads stay un-pinned. The selected road's id is shown in the OSM-edit panel.
### Modifying a road OSM edit mode behaves differently depending on whether a single video is focused (see **Single-video focus mode**):
- **Drag a yellow `•` pin** — moves that vertex to wherever you drop it. The change is saved immediately.
- **Click a yellow `+` ghost pin** between two vertices — inserts a new vertex at that midpoint. Useful when an OSM segment is too sparse for the curve. - **Non-focus** — click a road to enter the legacy single-road vertex editor (the only road remains visible; vertex pins appear). One road at a time.
- **Flip direction** — cycles `oneway` through forward (`1`) → reverse (`-1`) → undirected (`0`). Use when OSM has the heading wrong on a divided road. - **Focus mode** — click a road to **toggle multi-select** (yellow highlight). All roads stay visible. When the selection narrows to **exactly one** road, the vertex editor automatically engages on it (Flip / Simplify / Delete this road / Ignore for snap appear inline). Click the road again to drop back to multi-select.
### Modifying a road's vertices (single road active)
- **Drag a yellow `•` pin** — moves that vertex. Saved immediately.
- **Click a yellow `+` ghost pin** between two vertices — inserts a new vertex at that midpoint.
- **Flip direction** — cycles `oneway` through forward (`1`) → reverse (`-1`) → undirected (`0`).
- Vertex handles render at z ≥ 14; zoom in if you don't see them.
### Deleting vertices ### Deleting vertices
Two tools, depending on scope:
**A. Surgical — Shift-click + Del** **A. Surgical — Shift-click + Del**
1. Hold **Shift** and click a vertex pin → it turns red with an `✕`. 1. **Shift-click** a vertex pin → turns red with `✕`. Repeat to mark more.
2. Repeat to mark more. 2. Press **Del** → all marked vertices removed in one shot.
3. Press **Del** (or Backspace) → all marked vertices are removed in one shot. 3. **Clear marks** unselects without deleting. Shift-click a marked vertex to unmark.
4. The "Currently marked: N" label in the panel shows how many you've flagged. **Clear marks** unselects without deleting. 4. The road must keep ≥ 2 vertices.
5. Shift-click a marked vertex again to unmark it individually.
6. The road must keep ≥ 2 vertices; the backend rejects deletes that would break that.
**B. Sweeping — Simplify by tolerance** **B. Sweeping — Simplify by tolerance**
1. Use the **Simplify (m)** slider in the panel (0.520 m, default 2 m). Higher = more aggressive. 1. **Simplify (m)** slider (0.520 m, default 2 m). Higher = more aggressive.
2. Click **Simplify this road**. Runs Douglas-Peucker on the polyline: any vertex within tolerance of the simplified line is dropped. 2. Click **Simplify this road** Douglas-Peucker on the polyline.
3. Status bar reports `before → after` vertex counts. 3. Status bar reports `before → after` vertex counts.
4. Try 12 m for cleanup, 5+ m for heavy decimation. Re-running with the same tolerance is idempotent.
### Deleting / flipping the whole road ### Bulk road tools (focus mode only)
- **Delete this road** — removes the road outright (with confirmation).
- **Deselect road** — exits the per-road editing tool but keeps OSM edit mode on.
### Notes A "Bulk road tools — focus: <video>" panel appears when OSM edit mode is on inside single-video focus. Workflow:
- OSM road edits are **not** undoable via the Recent actions panel — they're treated like reference-data tweaks. Be deliberate, especially with delete and simplify.
- Dragging a vertex triggers a single `update_osm_road` write per drag. Bulk delete and simplify are also single writes. - **Click** roads to build a selection (yellow + thicker stroke).
- After a vertex change, the road line refreshes in real time but the surrounding draggable pins re-render — slight visual flicker is normal. - **Right-click** any road → instantly hides it (no selection needed).
- **Del** → moves all currently-selected roads into the hidden bucket and clears the selection.
- **Hide selected / Clear selection / Restore hidden** buttons in the panel mirror those shortcuts.
- **Merge selected** (≥ 2 picked) — concatenates connected ways into one. Endpoints within ~8 m of each other are clustered first, so OSM ways with near-but-not-touching endpoints still merge — both ends are snapped to the cluster centroid so the merged geometry has no visible gap. **Disconnected clusters become independent merged roads** (no false bridging).
- **Merge all visible** — same logic over every non-hidden road in the focused area.
- Branched clusters (any node with > 2 incident ways) are skipped rather than corrupted; the toast tells you how many.
Both **hidden** and **selection** sets are session-scoped — cleared on **Exit OSM edit mode** or focused-video change, never persisted.
### Ignore-for-snap (persistent)
Per-road toggle that keeps a road on the map for visual reference but excludes it from snap candidates. Useful for service roads / driveways that the snap should never pick.
- With **one road selected** in the vertex editor, the **Ignore for snap** button appears next to **Flip direction**.
- Ignored roads render in **grey** (vs. the default cyan) and are skipped by both single-asset and bulk snap.
- Stored as a `snap_ignored` column on the `roads` table — survives app restarts. Toggle again to re-enable.
### Whole-road actions
- **Delete this road** — removes the road (with confirmation).
- **Deselect road** — exits the per-road editor (non-focus only; in focus mode, click the road again).
### Per-session OSM edit undo
A 3-deep ring buffer scoped to the current OSM edit session captures the last edits so you can revert them without affecting the global asset audit log.
- **↶ Undo last edit (N)** button right under "Exit OSM edit mode" — visible in both focused and non-focused mode. Tooltip names the action it will undo.
- Covers: **flip direction, simplify road, delete vertices, drag vertex, ignore-for-snap toggle, hide road (right-click + Del), restore hidden**.
- **Not** undoable via this stack: merge selected / merge all visible (they consume source rows; this stack doesn't recreate them), creating new roads, deleting whole roads.
- Cleared on **Exit OSM edit mode**.
--- ---
@@ -149,6 +191,31 @@ Two tools, depending on scope:
--- ---
## Quality dashboard
A `<details>` panel in the right sidebar that scans the live asset set for likely-bad rows. Click **Run scan** the first time, **Refresh** thereafter.
Three checks (cap 500 hits each; totals shown even when capped):
- **Moved far from import** — modified rows whose current position is > 50 m from the original (import-time) lat/lng. Flags accidental drags.
- **Off track (far from metadata polyline)** — perpendicular distance to the asset's video metadata polyline exceeds the configured threshold (default **30 m**, tunable in Settings). Flags assets that were assigned to a video / side they don't actually belong to. Skipped silently for videos with no metadata loaded.
- **Missing side** — `side IS NULL`. Flags assets the labeller didn't tag.
For any non-zero count, **View** in the row launches a step-through:
- Zooms the map to z=18 on the first issue.
- **Prev / Next** in the same panel walks through the rest, one at a time at z=18.
### Post-snap quality check
Every snap entrypoint (single asset, bulk visible, snap by video, snap to road) runs a quality scan immediately after and surfaces a toast:
- **Green** — "No quality issues."
- **Red** — "N quality issues found (M moved · O off track · P no side)."
The scan reuses your configured off-track threshold. Toast auto-dismisses after 8 s; click `×` to dismiss sooner.
---
## Centerline override ## Centerline override
Some asset classes (expansion joint, vms gantry) belong on the road centerline, not the LHS/RHS offset. Settings → **Snap to centerline (instead of offset lane)** lets you tick those class names. Some asset classes (expansion joint, vms gantry) belong on the road centerline, not the LHS/RHS offset. Settings → **Snap to centerline (instead of offset lane)** lets you tick those class names.
@@ -161,6 +228,10 @@ Some asset classes (expansion joint, vms gantry) belong on the road centerline,
- On / off (when off, marker pins still appear but no image opens on click). - On / off (when off, marker pins still appear but no image opens on click).
- Width: S / M / L / XL. - Width: S / M / L / XL.
- Aspect ratio: 16:9 / 4:3 / 3:2 / 1:1. - Aspect ratio: 16:9 / 4:3 / 3:2 / 1:1.
- **Quality thresholds**
- **Off-track distance (m)** — perpendicular distance from metadata polyline beyond which an asset is flagged "off track" by the Quality dashboard and post-snap scan. Default 30 m. **Reset to 30 m** button restores the default. Persisted in localStorage. Lowering below ~25 m starts producing noise on curved / sparse polylines (chord cuts a corner the asset is on).
- **Diagnostics**
- **Performance mode** — while panning / zooming, render only basemap + asset markers + range paths (hide roads / scope / metadata / links / labels). Diagnostic; leave off for normal use.
- **Snap to centerline names** — list of asset_names that should snap to centerline. - **Snap to centerline names** — list of asset_names that should snap to centerline.
- **Image folder (offline)** — a local folder used to resolve `image_path` URLs to disk (HTTPS URLs always pass through unchanged). - **Image folder (offline)** — a local folder used to resolve `image_path` URLs to disk (HTTPS URLs always pass through unchanged).
@@ -181,17 +252,27 @@ OSM roads can be exported separately via **OSM tools… → Export current roads
## Keyboard shortcuts ## Keyboard shortcuts
| Key | Action | | Key | Context | Action |
|---|---| |---|---|---|
| `[` / `]` | Step prev / next asset in the filtered list | | `[` / `]` | filtered list | Step prev / next asset |
| `ESC` | Cancel: lasso / link-pick / draw-road / road-edit / image popup | | `ESC` | various | Cancel: lasso / link-pick / draw-road / road-edit / image popup / metadata focus / magnifier |
| `Delete` / `Backspace` | Unlink the currently-selected link line | | `Delete` / `Backspace` | link selected | Unlink the currently-selected link line |
| `Shift` + click | OSM vertex | Mark / unmark a road vertex (red ✕) |
| `Delete` / `Backspace` | OSM edit, marked vertices | Delete all marked vertices on the active road |
| `Delete` / `Backspace` | OSM edit, focus + selection | Hide all currently-selected roads (session-only) |
| Right-click | OSM edit, focus | Hide that road instantly |
--- ---
## Undo / Redo ## Undo / Redo
Every state-changing action writes an audit row. The **Recent actions** panel shows the last N. Click **Undo** on any row to revert; **Redo** on a previously undone row replays it. Multi-asset operations (bulk delete, distribute, auto-link) revert as a single atomic step. Road edits (OSM vertex drag, create / delete / flip) are not audited. Two independent stacks:
**Asset audit log** — every state-changing asset action writes an audit row. The **Recent actions** panel shows the last N. **Undo** on any row reverts; **Redo** replays. Multi-asset operations (bulk delete, distribute, auto-link, snap) revert atomically as a single step.
**OSM edit ring buffer** — separate 3-deep, in-session undo for road edits, with its own **↶ Undo last edit** button in the OSM edit panel. Covers flip / simplify / delete vertices / drag vertex / ignore-for-snap toggle / hide / restore-hidden. Cleared on Exit OSM edit mode. Merge and whole-road creation / deletion are **not** captured here — be deliberate with those.
Road edits do not appear in the asset audit log; the asset Undo and the OSM Undo never collide.
--- ---
@@ -210,3 +291,8 @@ Every state-changing action writes an audit row. The **Recent actions** panel sh
- **Streetlights not visible on satellite** — switch to **Google Satellite (deep zoom)** basemap and zoom to z≥22. - **Streetlights not visible on satellite** — switch to **Google Satellite (deep zoom)** basemap and zoom to z≥22.
- **Snap moves assets to the wrong side** — load the metadata polyline (gives true vehicle heading), or fix the OSM road's `oneway` direction. - **Snap moves assets to the wrong side** — load the metadata polyline (gives true vehicle heading), or fix the OSM road's `oneway` direction.
- **Re-importing wipes my edits** — re-import preserves rows where `modified=1` (manual moves) and rows the user deleted; only `modified=0` geometry is re-stamped from source. If you need a fresh start, use **Reset DB** in the Data section first. - **Re-importing wipes my edits** — re-import preserves rows where `modified=1` (manual moves) and rows the user deleted; only `modified=0` geometry is re-stamped from source. If you need a fresh start, use **Reset DB** in the Data section first.
- **Per-video metadata didn't auto-load on startup** — confirm the **Per-video metadata** row in the Loaded data section shows a non-zero count. If it does and the polylines aren't drawing, toggle **Metadata** in the Layers picker. If the count is zero after a previous import, the DB was wiped (Reset DB or manual `markers.db` delete) — re-pick the JSON.
- **Quality "Off track" flags too many / too few rows** — adjust **Settings → Quality thresholds → Off-track distance**. Default 30 m. Going below ~25 m starts producing noise on curved / sparse polylines (chord cuts a corner the asset is on).
- **Off-track flagged assets that look fine on the map** — check that the asset's `video_name` has metadata loaded. If the metadata is for a different video the chord-distance calc compares against the wrong track.
- **Bulk road tools panel doesn't show** — needs OSM edit mode AND a focused video. Tap a metadata polyline on the map (or check exactly one video in Filters) to focus, then press **Edit OSM roads**.
- **Magnifier shows a black circle** — basemap tile fetch hadn't completed when the lens map mounted; move the cursor a couple of pixels to trigger a re-render, or close and re-open the magnifier.

File diff suppressed because it is too large Load Diff

View File

@@ -104,6 +104,10 @@ async fn apply_schema(pool: &SqlitePool) -> Result<()> {
let _ = sqlx::query("ALTER TABLE roads ADD COLUMN highway TEXT") let _ = sqlx::query("ALTER TABLE roads ADD COLUMN highway TEXT")
.execute(pool) .execute(pool)
.await; .await;
let _ =
sqlx::query("ALTER TABLE roads ADD COLUMN snap_ignored INTEGER NOT NULL DEFAULT 0")
.execute(pool)
.await;
sqlx::query( sqlx::query(
"CREATE TABLE IF NOT EXISTS users ( "CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -189,7 +193,8 @@ async fn apply_schema(pool: &SqlitePool) -> Result<()> {
min_lng REAL NOT NULL, min_lng REAL NOT NULL,
max_lng REAL NOT NULL, max_lng REAL NOT NULL,
oneway INTEGER NOT NULL DEFAULT 0, oneway INTEGER NOT NULL DEFAULT 0,
highway TEXT highway TEXT,
snap_ignored INTEGER NOT NULL DEFAULT 0
)", )",
) )
.execute(pool) .execute(pool)
@@ -219,6 +224,18 @@ async fn apply_schema(pool: &SqlitePool) -> Result<()> {
.execute(pool) .execute(pool)
.await?; .await?;
// Per-video metadata polylines persisted across sessions; survives app
// restarts, wiped only by reset_database or explicit clear.
sqlx::query(
"CREATE TABLE IF NOT EXISTS video_metadata (
video_name TEXT PRIMARY KEY,
track_json TEXT NOT NULL,
imported_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
)
.execute(pool)
.await?;
sqlx::query( sqlx::query(
"CREATE TABLE IF NOT EXISTS action_history ( "CREATE TABLE IF NOT EXISTS action_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@@ -4,15 +4,18 @@ mod scope;
use commands::{ use commands::{
assets_bbox, delete_asset, delete_assets_by_video, get_assets_in_bbox, get_osm_roads, assets_bbox, delete_asset, delete_assets_by_video, get_assets_in_bbox, get_osm_roads,
get_scope_polygons, import_fixed_assets, import_kml_scope, import_osm_geojson, get_scope_polygons, import_assets_auto, import_fixed_assets, import_kml_scope,
import_osm_geojson,
import_range_assets, list_assets, read_video_polylines_json, list_recent_actions, merge_polylines, reset_assets_to_original, import_range_assets, list_assets, read_video_polylines_json, list_recent_actions, merge_polylines, reset_assets_to_original,
read_metadata_polyline, redo_action, reset_database, snap_assets_in_bbox, read_metadata_polyline, redo_action, reset_database, snap_assets_in_bbox,
add_class, auto_link_in_polygon, bulk_translate, clear_assets_by_type, add_class, auto_link_in_polygon, bulk_translate, clear_assets_by_type,
clear_osm_roads, clear_scope_polygons, data_source_counts, change_assets_side, clear_link, clear_manual_scope, clear_osm_roads, clear_scope_polygons, data_source_counts, change_assets_side, clear_link, clear_manual_scope,
create_osm_road, create_user, delete_road_vertices, set_link, simplify_road, create_osm_road, create_user, delete_road_vertices, set_link, simplify_road,
delete_roads_by_highway, export_osm_roads, find_duplicate_clusters, delete_roads_by_highway, export_osm_roads, find_duplicate_clusters,
save_video_metadata, get_video_metadata, clear_video_metadata,
flip_road_direction, generate_osm_for_bbox, generate_osm_near_assets, flip_road_direction, generate_osm_for_bbox, generate_osm_near_assets,
list_road_classes, merge_roads_by_name, set_road_snap_ignored, osm_road_snapshot, restore_road_state,
list_road_classes, merge_roads_by_name, merge_roads_by_ids, quality_report, assets_coords_for_ids,
delete_assets_bulk, delete_osm_road, delete_user, export_assets, delete_assets_bulk, delete_osm_road, delete_user, export_assets,
list_asset_names, list_classes, list_users, list_video_names, list_asset_names, list_classes, list_users, list_video_names,
rename_assets, restore_assets, set_assets_by_video_in_scope, rename_assets, restore_assets, set_assets_by_video_in_scope,
@@ -45,6 +48,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
import_fixed_assets, import_fixed_assets,
import_range_assets, import_range_assets,
import_assets_auto,
list_assets, list_assets,
get_assets_in_bbox, get_assets_in_bbox,
update_asset_position, update_asset_position,
@@ -87,11 +91,17 @@ pub fn run() {
delete_roads_by_highway, delete_roads_by_highway,
list_road_classes, list_road_classes,
merge_roads_by_name, merge_roads_by_name,
merge_roads_by_ids,
export_osm_roads, export_osm_roads,
generate_osm_for_bbox, generate_osm_for_bbox,
generate_osm_near_assets, generate_osm_near_assets,
assets_bbox, assets_bbox,
quality_report,
assets_coords_for_ids,
flip_road_direction, flip_road_direction,
set_road_snap_ignored,
osm_road_snapshot,
restore_road_state,
change_assets_side, change_assets_side,
auto_link_in_polygon, auto_link_in_polygon,
bulk_translate, bulk_translate,
@@ -99,6 +109,9 @@ pub fn run() {
clear_osm_roads, clear_osm_roads,
clear_scope_polygons, clear_scope_polygons,
data_source_counts, data_source_counts,
save_video_metadata,
get_video_metadata,
clear_video_metadata,
set_link, set_link,
clear_link clear_link
]) ])

File diff suppressed because it is too large Load Diff

272
src/LensView.tsx Normal file
View File

@@ -0,0 +1,272 @@
import { useEffect, useRef } from "react";
import maplibregl, { Map as MapLibreMap } from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { basemapById } from "./basemaps";
import type { Asset } from "./types";
import type { OsmRoadFC } from "./MapView";
type Props = {
basemapId: string;
centerLng: number;
centerLat: number;
zoom: number;
diameterPx: number;
osmRoads?: OsmRoadFC;
assets: Asset[];
metadataTrack?: Array<[number, number]>;
};
// Cursor-following magnifier: a circular MapLibre instance that re-projects
// to whatever the user's cursor is hovering on the main map, at +3 zoom. No
// deck.gl — assets and OSM roads render as native MapLibre layers from
// GeoJSON sources so each cursor move is a cheap setCenter() call instead of
// a full layer rebuild.
export function LensView({
basemapId,
centerLng,
centerLat,
zoom,
diameterPx,
osmRoads,
assets,
metadataTrack = [],
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<MapLibreMap | null>(null);
const styleReadyRef = useRef<boolean>(false);
// Hold refs to current data so installLayers can push the live state right
// after creating the empty sources — without this, the lens mounts blank
// until one of the data deps below changes (which often never happens).
const osmRoadsRef = useRef(osmRoads);
osmRoadsRef.current = osmRoads;
const assetsRef = useRef(assets);
assetsRef.current = assets;
const metadataTrackRef = useRef(metadataTrack);
metadataTrackRef.current = metadataTrack;
useEffect(() => {
if (!containerRef.current) return;
const map = new maplibregl.Map({
container: containerRef.current,
style: basemapById(basemapId).style,
center: [centerLng, centerLat],
zoom,
interactive: false,
attributionControl: false,
});
mapRef.current = map;
map.on("load", () => {
styleReadyRef.current = true;
installLayers(map);
pushAllData(map);
});
return () => {
mapRef.current = null;
styleReadyRef.current = false;
map.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Re-install layers when basemap changes (style swap drops sources/layers).
useEffect(() => {
const map = mapRef.current;
if (!map) return;
styleReadyRef.current = false;
map.setStyle(basemapById(basemapId).style);
map.once("styledata", () => {
styleReadyRef.current = true;
installLayers(map);
pushAllData(map);
});
}, [basemapId]);
function pushAllData(map: MapLibreMap) {
const osmSrc = map.getSource("lens-osm-roads") as
| maplibregl.GeoJSONSource
| undefined;
if (osmSrc && osmRoadsRef.current) {
osmSrc.setData(
osmRoadsRef.current as unknown as GeoJSON.FeatureCollection<GeoJSON.Geometry>,
);
}
const assetSrc = map.getSource("lens-assets") as
| maplibregl.GeoJSONSource
| undefined;
if (assetSrc) {
const features = assetsRef.current
.filter(
(a) => typeof a.lat === "number" && typeof a.lng === "number",
)
.map((a) => ({
type: "Feature" as const,
geometry: {
type: "Point" as const,
coordinates: [a.lng as number, a.lat as number],
},
properties: { id: a.id, side: a.side ?? "" },
}));
assetSrc.setData({ type: "FeatureCollection", features });
}
const trackSrc = map.getSource("lens-metadata") as
| maplibregl.GeoJSONSource
| undefined;
if (trackSrc) {
const t = metadataTrackRef.current;
if (t.length < 2) {
trackSrc.setData({ type: "FeatureCollection", features: [] });
} else {
trackSrc.setData({
type: "FeatureCollection",
features: [
{
type: "Feature" as const,
geometry: {
type: "LineString" as const,
coordinates: t.slice(),
},
properties: {},
},
],
});
}
}
}
// Push live data into the GeoJSON sources whenever they change.
useEffect(() => {
const map = mapRef.current;
if (!map || !styleReadyRef.current) return;
const src = map.getSource("lens-osm-roads") as
| maplibregl.GeoJSONSource
| undefined;
if (src && osmRoads) {
src.setData(
osmRoads as unknown as GeoJSON.FeatureCollection<GeoJSON.Geometry>,
);
}
}, [osmRoads]);
useEffect(() => {
const map = mapRef.current;
if (!map || !styleReadyRef.current) return;
const src = map.getSource("lens-assets") as
| maplibregl.GeoJSONSource
| undefined;
if (!src) return;
const features = assets
.filter((a) => typeof a.lat === "number" && typeof a.lng === "number")
.map((a) => ({
type: "Feature" as const,
geometry: {
type: "Point" as const,
coordinates: [a.lng as number, a.lat as number],
},
properties: { id: a.id, side: a.side ?? "" },
}));
src.setData({ type: "FeatureCollection", features });
}, [assets]);
useEffect(() => {
const map = mapRef.current;
if (!map || !styleReadyRef.current) return;
const src = map.getSource("lens-metadata") as
| maplibregl.GeoJSONSource
| undefined;
if (!src) return;
if (metadataTrack.length < 2) {
src.setData({ type: "FeatureCollection", features: [] });
return;
}
src.setData({
type: "FeatureCollection",
features: [
{
type: "Feature" as const,
geometry: {
type: "LineString" as const,
coordinates: metadataTrack.slice(),
},
properties: {},
},
],
});
}, [metadataTrack]);
// Track the cursor world coordinate via prop changes.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (!Number.isFinite(centerLng) || !Number.isFinite(centerLat)) return;
map.jumpTo({ center: [centerLng, centerLat], zoom });
}, [centerLng, centerLat, zoom]);
function installLayers(map: MapLibreMap) {
if (!map.getSource("lens-osm-roads")) {
map.addSource("lens-osm-roads", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: "lens-osm-roads",
type: "line",
source: "lens-osm-roads",
paint: {
"line-color": "#00e5ff",
"line-width": 2,
"line-opacity": 0.85,
},
});
}
if (!map.getSource("lens-metadata")) {
map.addSource("lens-metadata", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: "lens-metadata",
type: "line",
source: "lens-metadata",
paint: {
"line-color": "#27ae60",
"line-width": 2,
"line-opacity": 0.9,
"line-dasharray": [2, 2],
},
});
}
if (!map.getSource("lens-assets")) {
map.addSource("lens-assets", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: "lens-assets",
type: "circle",
source: "lens-assets",
paint: {
"circle-radius": 5,
"circle-color": "#e74c3c",
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 1,
},
});
}
}
return (
<div
ref={containerRef}
style={{
width: diameterPx,
height: diameterPx,
borderRadius: "50%",
overflow: "hidden",
boxShadow: "0 4px 16px rgba(0,0,0,0.45)",
border: "2px solid #fff",
background: "#000",
}}
/>
);
}

View File

@@ -27,7 +27,7 @@ export type OsmRoadFC = {
features: Array<{ features: Array<{
type: "Feature"; type: "Feature";
geometry: unknown; geometry: unknown;
properties: { id: number; name: string | null }; properties: { id: number; name: string | null; snap_ignored?: boolean };
}>; }>;
}; };
@@ -76,6 +76,9 @@ type Props = {
osmEditMode?: boolean; osmEditMode?: boolean;
editingRoadId?: number | null; editingRoadId?: number | null;
onPickRoad?: (id: number) => void; onPickRoad?: (id: number) => void;
onRoadRightClick?: (id: number) => void;
selectedRoadIds?: Set<number>;
multiSelectActive?: boolean;
onUpdateRoad?: (id: number, coords: Array<[number, number]>) => void; onUpdateRoad?: (id: number, coords: Array<[number, number]>) => void;
roadOffsetMeters?: number; roadOffsetMeters?: number;
drawRoadMode?: boolean; drawRoadMode?: boolean;
@@ -89,6 +92,12 @@ type Props = {
) => void; ) => void;
highlightedIds?: number[]; highlightedIds?: number[];
anchorIds?: number[]; anchorIds?: number[];
onCursorWorld?: (
lng: number,
lat: number,
screenX: number,
screenY: number,
) => void;
}; };
// Filled rightward-pointing arrow head, white stroke for legibility on any // Filled rightward-pointing arrow head, white stroke for legibility on any
@@ -138,6 +147,9 @@ export function MapView({
osmEditMode = false, osmEditMode = false,
editingRoadId = null, editingRoadId = null,
onPickRoad, onPickRoad,
onRoadRightClick,
selectedRoadIds,
multiSelectActive = false,
onUpdateRoad, onUpdateRoad,
roadOffsetMeters = 4, roadOffsetMeters = 4,
drawRoadMode = false, drawRoadMode = false,
@@ -148,6 +160,7 @@ export function MapView({
onLassoComplete, onLassoComplete,
highlightedIds = [], highlightedIds = [],
anchorIds = [], anchorIds = [],
onCursorWorld,
}: Props) { }: Props) {
// Diagnostic counters — visible in DevTools console. Should stay flat // Diagnostic counters — visible in DevTools console. Should stay flat
// during pure pan/zoom; spikes on filter or import. // during pure pan/zoom; spikes on filter or import.
@@ -179,6 +192,10 @@ export function MapView({
osmEditModeRef.current = osmEditMode; osmEditModeRef.current = osmEditMode;
const onPickRoadRef = useRef(onPickRoad); const onPickRoadRef = useRef(onPickRoad);
onPickRoadRef.current = onPickRoad; onPickRoadRef.current = onPickRoad;
const onRoadRightClickRef = useRef(onRoadRightClick);
onRoadRightClickRef.current = onRoadRightClick;
const onCursorWorldRef = useRef(onCursorWorld);
onCursorWorldRef.current = onCursorWorld;
const drawRoadModeRef = useRef(drawRoadMode); const drawRoadModeRef = useRef(drawRoadMode);
drawRoadModeRef.current = drawRoadMode; drawRoadModeRef.current = drawRoadMode;
const onDrawRoadAddPointRef = useRef(onDrawRoadAddPoint); const onDrawRoadAddPointRef = useRef(onDrawRoadAddPoint);
@@ -208,20 +225,46 @@ export function MapView({
map.on("contextmenu", (e) => { map.on("contextmenu", (e) => {
const overlay = overlayRef.current; const overlay = overlayRef.current;
if (!overlay || !onAssetRightClickRef.current) return; if (!overlay) return;
const { x, y } = e.point; const { x, y } = e.point;
const info = overlay.pickObject({ x, y, radius: 8 }) as const info = overlay.pickObject({ x, y, radius: 8 }) as
| { object?: unknown; layer?: { id?: string } } | {
object?: unknown;
layer?: { id?: string };
sourceLayer?: { id?: string };
}
| null; | null;
if (!info || !info.object) return; if (!info || !info.object) return;
// An asset record carries `row_id` and `asset_type`; ignore picks that // Road right-click takes priority when in OSM edit mode.
// landed on roads/scopes/metadata. if (
osmEditModeRef.current &&
info.layer?.id === "osm-roads" &&
onRoadRightClickRef.current
) {
const f = info.object as { properties?: { id?: number } };
const rid = f.properties?.id;
if (typeof rid === "number") {
e.preventDefault();
onRoadRightClickRef.current(rid);
return;
}
}
if (!onAssetRightClickRef.current) return;
const obj = info.object as Partial<Asset>; const obj = info.object as Partial<Asset>;
if (typeof obj.row_id !== "string" || typeof obj.id !== "number") return; if (typeof obj.row_id !== "string" || typeof obj.id !== "number") return;
e.preventDefault(); e.preventDefault();
onAssetRightClickRef.current(obj as Asset); onAssetRightClickRef.current(obj as Asset);
}); });
map.on("mousemove", (e) => {
const cb = onCursorWorldRef.current;
if (!cb) return;
cb(e.lngLat.lng, e.lngLat.lat, e.point.x, e.point.y);
});
map.on("mouseleave", () => {
onCursorWorldRef.current?.(NaN, NaN, NaN, NaN);
});
map.on("moveend", () => { map.on("moveend", () => {
setIsInteracting(false); setIsInteracting(false);
// Sync the React viewport mirror once per gesture so programmatic // Sync the React viewport mirror once per gesture so programmatic
@@ -448,8 +491,14 @@ export function MapView({
const layers: unknown[] = []; const layers: unknown[] = [];
// Vertex-edit mode collapses to the single road being dragged so other
// roads don't clutter the editor. But when multi-select is active (focused
// video + bulk select / hide / merge workflow), keep all roads visible.
const visibleOsm: OsmRoadFC | undefined = const visibleOsm: OsmRoadFC | undefined =
osmEditMode && editingRoadId !== null && osmRoads osmEditMode &&
editingRoadId !== null &&
!multiSelectActive &&
osmRoads
? { ? {
type: "FeatureCollection", type: "FeatureCollection",
features: osmRoads.features.filter( features: osmRoads.features.filter(
@@ -480,10 +529,28 @@ export function MapView({
data: visibleOsm, data: visibleOsm,
stroked: true, stroked: true,
filled: false, filled: false,
getLineColor: [0, 229, 255, 255], getLineColor: (f: unknown) => {
getLineWidth: 3, const p = (f as { properties?: { id?: number; snap_ignored?: 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];
return [0, 229, 255, 255];
},
getLineWidth: (f: unknown) => {
const p = (f as { properties?: { id?: number } }).properties;
if (p && selectedRoadIds && typeof p.id === "number" && selectedRoadIds.has(p.id)) {
return 5;
}
return 3;
},
lineWidthUnits: "pixels", lineWidthUnits: "pixels",
pickable: osmEditMode, pickable: osmEditMode,
updateTriggers: {
getLineColor: [selectedRoadIds],
getLineWidth: [selectedRoadIds],
},
}), }),
); );
if (osmEditMode && roadOffsetMeters > 0) { if (osmEditMode && roadOffsetMeters > 0) {
@@ -1406,6 +1473,8 @@ export function MapView({
zoomBand, zoomBand,
isInteracting, isInteracting,
perfMode, perfMode,
selectedRoadIds,
multiSelectActive,
]); ]);
// Manage draggable HTML markers + per-vertex popup for the actively-edited asset. // Manage draggable HTML markers + per-vertex popup for the actively-edited asset.
@@ -1476,7 +1545,22 @@ export function MapView({
} }
cap.appendChild(document.createElement("br")); cap.appendChild(document.createElement("br"));
const small = document.createElement("small"); const small = document.createElement("small");
small.textContent = selectedAsset.row_id; // row_id · video_name on a single line so users can read both
// at a glance without the popup growing taller.
const rowSpan = document.createElement("span");
rowSpan.textContent = selectedAsset.row_id;
small.appendChild(rowSpan);
if (selectedAsset.video_name && selectedAsset.video_name !== "Not available") {
const sep = document.createElement("span");
sep.style.color = "#888";
sep.style.margin = "0 6px";
sep.textContent = "·";
small.appendChild(sep);
const vidSpan = document.createElement("span");
vidSpan.style.color = "#666";
vidSpan.textContent = selectedAsset.video_name;
small.appendChild(vidSpan);
}
cap.appendChild(small); cap.appendChild(small);
root.appendChild(cap); root.appendChild(cap);

View File

@@ -132,9 +132,9 @@ const STUBS: Record<string, Stub> = {
data_source_counts: () => ({ data_source_counts: () => ({
fixed_assets: FAKE_ASSETS.length, fixed_assets: FAKE_ASSETS.length,
range_assets: 0, range_assets: 0,
scope_polygons: 0,
osm_roads: 0, osm_roads: 0,
metadata_polylines: 0, scope_features: 0,
video_metadata: 0,
}), }),
list_video_names: () => list_video_names: () =>
Array.from(new Set(FAKE_ASSETS.map((a) => a.video_name))), Array.from(new Set(FAKE_ASSETS.map((a) => a.video_name))),
@@ -145,7 +145,15 @@ const STUBS: Record<string, Stub> = {
get_scope_polygons: () => [], get_scope_polygons: () => [],
read_video_polylines_json: () => ({}), read_video_polylines_json: () => ({}),
read_metadata_kml: () => [], read_metadata_kml: () => [],
list_osm_roads: () => ({ type: "FeatureCollection", features: [] }), // Backend rename — App invokes get_osm_roads; the legacy list_osm_roads
// alias is kept so older browser fixtures don't crash.
get_osm_roads: () => [],
list_osm_roads: () => [],
get_video_metadata: () => ({}),
save_video_metadata: () => 0,
clear_video_metadata: () => 0,
osm_road_snapshot: () => [],
restore_road_state: () => 0,
}; };
export function invoke<T = unknown>( export function invoke<T = unknown>(