osm edits
This commit is contained in:
301
FORMATS.md
Normal file
301
FORMATS.md
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
# Import & Export Formats
|
||||||
|
|
||||||
|
What each Import button accepts, plus what every Export option produces. Sample snippets included so you can shape data outside the tool and feed it in.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Imports
|
||||||
|
|
||||||
|
### Fixed assets (JSON)
|
||||||
|
Source-of-truth shape used by the labelling pipeline. Each row is one point asset.
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"row_id": "406_473_0.04_1",
|
||||||
|
"asset_name": "Double_Arm_Street_Light",
|
||||||
|
"video_name": "20250802150330_000000",
|
||||||
|
"image_path": "https://auditor-master-images.seekright.com/.../406_473_0.04_1.jpeg",
|
||||||
|
"coord": ["17.39869", "78.34701"],
|
||||||
|
"deleted": false,
|
||||||
|
"side": "Left"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `row_id` | string | yes | Unique key. Re-import upserts on this. |
|
||||||
|
| `asset_name` | string | yes | Class label, e.g. `Double_Arm_Street_Light`. |
|
||||||
|
| `video_name` | string | yes | Video the asset was captured in. `"Not available"` is treated as NA. |
|
||||||
|
| `image_path` | string \| null | no | HTTPS URL or local path. Used by the popup. |
|
||||||
|
| `coord` | `[lat, lng]` | yes | Strings or numbers. Order is **lat first**. |
|
||||||
|
| `deleted` | bool | no | Default `false`. Re-import never resurrects rows the user deleted. |
|
||||||
|
| `side` | `"Left" \| "Right" \| "LHS" \| "RHS" \| null` | no | If absent, derived from `image_path` (e.g. a `/LHS/` or `/RHS/` segment). |
|
||||||
|
|
||||||
|
Re-import behaviour: rows the user has manually moved (`modified=1`) or deleted are **not** overwritten — only `modified=0` geometry is re-stamped from source.
|
||||||
|
|
||||||
|
### Range assets (JSON)
|
||||||
|
Same idea but with start / mid / end coordinates and three image paths.
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"row_id": "406_473_0.50_2",
|
||||||
|
"asset_name": "Crash_Barrier",
|
||||||
|
"video_name": "20250802150330_000000",
|
||||||
|
"coord_lat": 17.4002,
|
||||||
|
"coord_lng": 78.3445,
|
||||||
|
"start_coord_lat": 17.40015,
|
||||||
|
"start_coord_lng": 78.34448,
|
||||||
|
"end_coord_lat": 17.40025,
|
||||||
|
"end_coord_lng": 78.34452,
|
||||||
|
"image_path1": "https://.../start.jpg",
|
||||||
|
"image_path2": "https://.../mid.jpg",
|
||||||
|
"image_path3": "https://.../end.jpg",
|
||||||
|
"deleted": false,
|
||||||
|
"side": "Left"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`coord_*` may be strings or numbers. Same upsert / preserve rules as fixed assets.
|
||||||
|
|
||||||
|
### KML scope
|
||||||
|
Standard KML 2.2. `Polygon`s and `LineString`s inside `Placemark`s become scope features. Polygons gate by point-in-polygon; LineStrings are 30 m proximity buffers.
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||||
|
<Document>
|
||||||
|
<Placemark>
|
||||||
|
<name>HYDTOT corridor</name>
|
||||||
|
<Polygon><outerBoundaryIs><LinearRing><coordinates>
|
||||||
|
78.34,17.39,0 78.36,17.40,0 78.35,17.41,0 78.34,17.39,0
|
||||||
|
</coordinates></LinearRing></outerBoundaryIs></Polygon>
|
||||||
|
</Placemark>
|
||||||
|
</Document>
|
||||||
|
</kml>
|
||||||
|
```
|
||||||
|
|
||||||
|
Importing a new KML replaces the entire scope set.
|
||||||
|
|
||||||
|
### Per-video metadata (JSON)
|
||||||
|
Multi-video GPS polylines keyed by `video_name`. Used for direction inference (snap-to-road priority: metadata → OSM `oneway` → position fallback) and on-map display.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"2026_0330_094759_F": [[
|
||||||
|
[17.398456, 78.347281],
|
||||||
|
[17.398522, 78.347233],
|
||||||
|
[17.398581, 78.347192]
|
||||||
|
]],
|
||||||
|
"2026_0330_095259_F": [[
|
||||||
|
[17.410322, 78.320736],
|
||||||
|
[17.410367, 78.320547]
|
||||||
|
]]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Structure: `{ video_name: [[ [lat, lng], … ]] }`. Each value is an array containing **one** polyline (the outer wrapper is for compatibility with multi-segment legacy formats — the importer reads only the first inner array).
|
||||||
|
|
||||||
|
Coordinates are `[lat, lng]`; the importer swaps to `[lng, lat]` internally. Garbage points are filtered out automatically:
|
||||||
|
- Anything within ~100 m of `(0, 0)`.
|
||||||
|
- Non-finite or out-of-range lat/lng.
|
||||||
|
- Jumps > 5 km between consecutive points.
|
||||||
|
|
||||||
|
Stored only in memory (cleared by the **Loaded data → Clear** button or app restart).
|
||||||
|
|
||||||
|
### Metadata polyline (legacy single-track)
|
||||||
|
Single-video legacy format used by the old FastAPI pipeline.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"track": [
|
||||||
|
[17.398, 78.347],
|
||||||
|
[17.399, 78.346]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`track` array of `[lat, lng]`. Used as the single source-of-truth track when no per-video map is loaded. Per-video JSON supersedes this.
|
||||||
|
|
||||||
|
### OSM roads (GeoJSON)
|
||||||
|
A `FeatureCollection` of `LineString` features. Each feature's `properties` are stored on the `roads` table.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {
|
||||||
|
"type": "LineString",
|
||||||
|
"coordinates": [
|
||||||
|
[78.347281, 17.398456],
|
||||||
|
[78.347233, 17.398522]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"id": 12345,
|
||||||
|
"name": "ORR Service Road",
|
||||||
|
"highway": "service",
|
||||||
|
"oneway": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Property | Type | Required | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `id` | integer | no | If absent the importer assigns a sequential id. |
|
||||||
|
| `name` | string | no | Used by Merge-by-name and the popup. |
|
||||||
|
| `highway` | string | no | OSM class — `motorway`, `trunk`, `primary`, `service`, etc. Used by Prune. |
|
||||||
|
| `oneway` | int | no | `1` = forward, `-1` = reverse, `0` (or absent) = undirected. `motorway` and `roundabout` default to `1` if `oneway` is missing. |
|
||||||
|
|
||||||
|
GeoJSON convention: `coordinates` is `[lng, lat]` (the *opposite* of every JSON above). The importer expects the standard order.
|
||||||
|
|
||||||
|
You can also generate this file in-app via **OSM tools… → Generate visible bbox** (uses the Overpass API).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exports
|
||||||
|
|
||||||
|
The **Export data…** button writes every non-deleted asset in one of four formats. The Save dialog filters by extension.
|
||||||
|
|
||||||
|
### `.json` — Source JSON (round-trippable)
|
||||||
|
Same shape `import_fixed_assets` and `import_range_assets` accept, split into two arrays so each can be fed back through its matching importer.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fixed": [
|
||||||
|
{
|
||||||
|
"row_id": "406_473_0.04_1",
|
||||||
|
"asset_name": "Double_Arm_Street_Light",
|
||||||
|
"video_name": "20250802150330_000000",
|
||||||
|
"image_path": "https://.../406_473_0.04_1.jpeg",
|
||||||
|
"coord": [17.39869, 78.34701],
|
||||||
|
"deleted": false,
|
||||||
|
"side": "Left"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"range": [
|
||||||
|
{
|
||||||
|
"row_id": "406_473_0.50_2",
|
||||||
|
"asset_name": "Crash_Barrier",
|
||||||
|
"video_name": "20250802150330_000000",
|
||||||
|
"coord_lat": 17.4002,
|
||||||
|
"coord_lng": 78.3445,
|
||||||
|
"start_coord_lat": 17.40015,
|
||||||
|
"start_coord_lng": 78.34448,
|
||||||
|
"end_coord_lat": 17.40025,
|
||||||
|
"end_coord_lng": 78.34452,
|
||||||
|
"image_path1": "https://.../start.jpg",
|
||||||
|
"image_path2": "https://.../mid.jpg",
|
||||||
|
"image_path3": "https://.../end.jpg",
|
||||||
|
"deleted": false,
|
||||||
|
"side": "Left"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this for round-trip backup / sharing edits with a colleague.
|
||||||
|
|
||||||
|
### `.geojson` — FeatureCollection
|
||||||
|
Standard GeoJSON for any GIS tool. Fixed → `Point`, range → `LineString` over `[start, mid, end]`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": { "type": "Point", "coordinates": [78.34701, 17.39869] },
|
||||||
|
"properties": {
|
||||||
|
"row_id": "406_473_0.04_1",
|
||||||
|
"asset_type": "fixed",
|
||||||
|
"asset_name": "Double_Arm_Street_Light",
|
||||||
|
"video_name": "20250802150330_000000",
|
||||||
|
"side": "Left",
|
||||||
|
"in_scope": 1,
|
||||||
|
"modified": 1,
|
||||||
|
"image_path": "https://.../...jpeg",
|
||||||
|
"image_path1": null,
|
||||||
|
"image_path2": null,
|
||||||
|
"image_path3": null,
|
||||||
|
"link_pair_id": 882,
|
||||||
|
"link_locked": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use for QGIS / Mapbox / shipping data downstream.
|
||||||
|
|
||||||
|
### `.kml` — KML Placemarks
|
||||||
|
Each asset is a Placemark with name, description, and geometry. Useful for Google Earth and any KML-aware GIS.
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
|
||||||
|
<Placemark>
|
||||||
|
<name>Double_Arm_Street_Light · 406_473_0.04_1</name>
|
||||||
|
<description>type: fixed
|
||||||
|
video: 20250802150330_000000
|
||||||
|
side: Left
|
||||||
|
modified: 1</description>
|
||||||
|
<Point><coordinates>78.34701,17.39869,0</coordinates></Point>
|
||||||
|
</Placemark>
|
||||||
|
</Document></kml>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `.csv` — flat row dump
|
||||||
|
Spreadsheet-friendly. Header row first.
|
||||||
|
|
||||||
|
```
|
||||||
|
id,row_id,asset_type,asset_name,video_name,side,in_scope,deleted,modified,lat,lng,start_lat,start_lng,end_lat,end_lng,link_pair_id,link_locked,image_path,image_path1,image_path2,image_path3
|
||||||
|
123,406_473_0.04_1,fixed,Double_Arm_Street_Light,20250802150330_000000,Left,1,0,1,17.39869,78.34701,,,,,,,,https://.../406_473_0.04_1.jpeg,,,
|
||||||
|
```
|
||||||
|
|
||||||
|
### `.geojson` — OSM roads (separate export)
|
||||||
|
**OSM tools… → Export current roads** writes the entire `roads` table as a GeoJSON FeatureCollection. Same shape as the OSM import — feeds straight back into any GIS tool or back into this app on another machine.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {
|
||||||
|
"type": "LineString",
|
||||||
|
"coordinates": [[78.347281, 17.398456], [78.347233, 17.398522]]
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"id": 12345,
|
||||||
|
"name": "ORR Service Road",
|
||||||
|
"oneway": 1,
|
||||||
|
"highway": "service"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coordinate-order cheat sheet
|
||||||
|
|
||||||
|
| Source | Order |
|
||||||
|
|---|---|
|
||||||
|
| Fixed asset JSON `coord` | `[lat, lng]` |
|
||||||
|
| Range asset JSON `coord_*` | scalar lat / lng |
|
||||||
|
| Per-video metadata JSON | `[lat, lng]` |
|
||||||
|
| Legacy single-track metadata | `[lat, lng]` |
|
||||||
|
| KML `<coordinates>` | `lng,lat,alt` (KML standard) |
|
||||||
|
| GeoJSON anywhere | `[lng, lat]` (GeoJSON standard) |
|
||||||
|
| App / DB internals | `[lng, lat]` (deck.gl convention) |
|
||||||
|
|
||||||
|
When in doubt: **JSON coords here are lat-first, GeoJSON / KML are lng-first**. The importers swap as needed; the exports always emit the format's native order.
|
||||||
40
README.md
40
README.md
@@ -96,11 +96,47 @@ Range assets (start / mid / end) translate as a rigid body — all three vertice
|
|||||||
|
|
||||||
## OSM road editing
|
## OSM road editing
|
||||||
|
|
||||||
|
### 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 (Overpass), import existing GeoJSON, export current roads, prune small road classes.
|
||||||
- **OSM edit mode** — drag any road's vertex to refine the centerline. Markers are gated by zoom ≥ 14 and capped at 80 vertices / 40 ghost dots per viewport so the map stays responsive.
|
- **Edit OSM roads** button — toggles OSM edit mode. While on, every other layer is dimmed and only OSM roads are interactive.
|
||||||
- **Flip road direction** — cycle a road's `oneway` between 0 / 1 / -1 when it's wrong.
|
|
||||||
- **Lane offset (m)** — controls the parallel offset used by snap-to-road. Range 0.1–30 m.
|
- **Lane offset (m)** — controls the parallel offset used by snap-to-road. Range 0.1–30 m.
|
||||||
|
|
||||||
|
### Picking a road to edit
|
||||||
|
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
|
||||||
|
- **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.
|
||||||
|
- **Flip direction** — cycles `oneway` through forward (`1`) → reverse (`-1`) → undirected (`0`). Use when OSM has the heading wrong on a divided road.
|
||||||
|
|
||||||
|
### Deleting vertices
|
||||||
|
Two tools, depending on scope:
|
||||||
|
|
||||||
|
**A. Surgical — Shift-click + Del**
|
||||||
|
1. Hold **Shift** and click a vertex pin → it turns red with an `✕`.
|
||||||
|
2. Repeat to mark more.
|
||||||
|
3. Press **Del** (or Backspace) → all marked vertices are removed in one shot.
|
||||||
|
4. The "Currently marked: N" label in the panel shows how many you've flagged. **Clear marks** unselects without deleting.
|
||||||
|
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**
|
||||||
|
1. Use the **Simplify (m)** slider in the panel (0.5–20 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.
|
||||||
|
3. Status bar reports `before → after` vertex counts.
|
||||||
|
4. Try 1–2 m for cleanup, 5+ m for heavy decimation. Re-running with the same tolerance is idempotent.
|
||||||
|
|
||||||
|
### Deleting / flipping the whole road
|
||||||
|
- **Delete this road** — removes the road outright (with confirmation).
|
||||||
|
- **Deselect road** — exits the per-road editing tool but keeps OSM edit mode on.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- 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.
|
||||||
|
- After a vertex change, the road line refreshes in real time but the surrounding draggable pins re-render — slight visual flicker is normal.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Snap-to-road
|
## Snap-to-road
|
||||||
|
|||||||
@@ -426,6 +426,78 @@ pub async fn import_kml_scope(
|
|||||||
Ok(parsed.len())
|
Ok(parsed.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read a multi-video metadata polyline file shaped as
|
||||||
|
/// `{ video_name: [[[lat, lng], ...]] }`. Returns a map from video_name to a
|
||||||
|
/// `[lng, lat]` track (deck.gl / GeoJSON convention).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_video_polylines_json(
|
||||||
|
path: String,
|
||||||
|
) -> Result<std::collections::HashMap<String, Vec<[f64; 2]>>, String> {
|
||||||
|
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
|
||||||
|
let parsed: serde_json::Map<String, serde_json::Value> =
|
||||||
|
serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON: {}", e))?;
|
||||||
|
let mut out: std::collections::HashMap<String, Vec<[f64; 2]>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for (video_name, val) in parsed {
|
||||||
|
let outer = match val.as_array() {
|
||||||
|
Some(a) if !a.is_empty() => a,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let pts_json = match outer[0].as_array() {
|
||||||
|
Some(a) => a,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let mut track: Vec<[f64; 2]> = Vec::with_capacity(pts_json.len());
|
||||||
|
for pt in pts_json {
|
||||||
|
let arr = match pt.as_array() {
|
||||||
|
Some(a) if a.len() >= 2 => a,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
// Source is [lat, lng]; downstream code expects [lng, lat].
|
||||||
|
let lat = arr[0].as_f64();
|
||||||
|
let lng = arr[1].as_f64();
|
||||||
|
if let (Some(la), Some(lo)) = (lat, lng) {
|
||||||
|
// Drop sentinel / garbage coords. Anything within ~100 m of
|
||||||
|
// the (0,0) origin is the standard "no GPS fix" placeholder.
|
||||||
|
if la.abs() < 1e-3 && lo.abs() < 1e-3 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !la.is_finite() || !lo.is_finite() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !(-90.0..=90.0).contains(&la) || !(-180.0..=180.0).contains(&lo) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Break implausible jumps (> ~5 km between consecutive points).
|
||||||
|
// Most consumer GPS tracks sample faster than that; a long jump
|
||||||
|
// signals a missing point, so skip it rather than draw a line
|
||||||
|
// across the map.
|
||||||
|
if let Some(&[plng, plat]) = track.last() {
|
||||||
|
let dlat = la - plat;
|
||||||
|
let dlng = lo - plng;
|
||||||
|
// Cheap planar approximation in degrees → meters.
|
||||||
|
let m_per_deg_lat = 111_000.0;
|
||||||
|
let m_per_deg_lng = 111_000.0 * la.to_radians().cos().max(0.1);
|
||||||
|
let dist = ((dlat * m_per_deg_lat).powi(2)
|
||||||
|
+ (dlng * m_per_deg_lng).powi(2))
|
||||||
|
.sqrt();
|
||||||
|
if dist > 5_000.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
track.push([lo, la]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if track.len() >= 2 {
|
||||||
|
out.insert(video_name, track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.is_empty() {
|
||||||
|
return Err("no usable polylines in file".to_string());
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_scope_polygons(
|
pub async fn get_scope_polygons(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -3315,6 +3387,113 @@ pub async fn delete_osm_road(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove a list of vertex indices from a road's polyline. Indices are
|
||||||
|
/// zero-based and refer to the *current* coords array. The road must keep at
|
||||||
|
/// least 2 vertices afterwards.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_road_vertices(
|
||||||
|
id: i64,
|
||||||
|
indices: Vec<usize>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let row: (String,) = sqlx::query_as("SELECT geojson FROM roads WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let val: serde_json::Value =
|
||||||
|
serde_json::from_str(&row.0).map_err(|e| e.to_string())?;
|
||||||
|
let arr = val
|
||||||
|
.get("coordinates")
|
||||||
|
.and_then(|c| c.as_array())
|
||||||
|
.ok_or_else(|| "road has no coordinates".to_string())?;
|
||||||
|
let mut coords: Vec<[f64; 2]> = arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| p.as_array())
|
||||||
|
.filter_map(|p| {
|
||||||
|
Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
use std::collections::HashSet;
|
||||||
|
let drop: HashSet<usize> = indices.into_iter().collect();
|
||||||
|
let new_coords: Vec<[f64; 2]> = coords
|
||||||
|
.drain(..)
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(i, _)| !drop.contains(i))
|
||||||
|
.map(|(_, c)| c)
|
||||||
|
.collect();
|
||||||
|
if new_coords.len() < 2 {
|
||||||
|
return Err(
|
||||||
|
"removing those vertices would leave < 2 — road needs at least 2".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let removed = drop.len();
|
||||||
|
update_osm_road(id, new_coords, state).await?;
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Douglas-Peucker simplification of a road's polyline. Tolerance is in metres.
|
||||||
|
/// Converted to a degrees epsilon at the road's centroid latitude — accurate
|
||||||
|
/// enough for cleanup tolerances of 0.5–10 m.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn simplify_road(
|
||||||
|
id: i64,
|
||||||
|
tolerance_m: f64,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(usize, usize), String> {
|
||||||
|
if tolerance_m <= 0.0 {
|
||||||
|
return Err("tolerance must be > 0".into());
|
||||||
|
}
|
||||||
|
let row: (String,) = sqlx::query_as("SELECT geojson FROM roads WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let val: serde_json::Value =
|
||||||
|
serde_json::from_str(&row.0).map_err(|e| e.to_string())?;
|
||||||
|
let arr = val
|
||||||
|
.get("coordinates")
|
||||||
|
.and_then(|c| c.as_array())
|
||||||
|
.ok_or_else(|| "road has no coordinates".to_string())?;
|
||||||
|
let coords: Vec<[f64; 2]> = arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| p.as_array())
|
||||||
|
.filter_map(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?]))
|
||||||
|
.collect();
|
||||||
|
let before = coords.len();
|
||||||
|
if before < 3 {
|
||||||
|
return Ok((before, before));
|
||||||
|
}
|
||||||
|
// Convert tolerance metres → degrees, with a cos-lat correction for lng.
|
||||||
|
let mid_lat = coords[before / 2][1];
|
||||||
|
let m_per_deg_lat = 111_000.0_f64;
|
||||||
|
let m_per_deg_lng = 111_000.0_f64 * mid_lat.to_radians().cos().abs().max(0.1);
|
||||||
|
// Project to a local "metres" space so DP epsilon is uniform.
|
||||||
|
let projected: Vec<[f64; 2]> = coords
|
||||||
|
.iter()
|
||||||
|
.map(|c| [c[0] * m_per_deg_lng, c[1] * m_per_deg_lat])
|
||||||
|
.collect();
|
||||||
|
use geo::algorithm::Simplify;
|
||||||
|
let line = LineString::from(
|
||||||
|
projected
|
||||||
|
.iter()
|
||||||
|
.map(|c| (c[0], c[1]))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
let simplified = line.simplify(tolerance_m);
|
||||||
|
let new_coords: Vec<[f64; 2]> = simplified
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|c| [c.x / m_per_deg_lng, c.y / m_per_deg_lat])
|
||||||
|
.collect();
|
||||||
|
if new_coords.len() < 2 {
|
||||||
|
return Err("simplification would leave < 2 vertices".into());
|
||||||
|
}
|
||||||
|
let after = new_coords.len();
|
||||||
|
update_osm_road(id, new_coords, state).await?;
|
||||||
|
Ok((before, after))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn update_osm_road(
|
pub async fn update_osm_road(
|
||||||
id: i64,
|
id: i64,
|
||||||
@@ -3986,6 +4165,87 @@ pub async fn reset_database(state: State<'_, AppState>) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DataSourceCounts {
|
||||||
|
pub fixed_assets: i64,
|
||||||
|
pub range_assets: i64,
|
||||||
|
pub osm_roads: i64,
|
||||||
|
pub scope_features: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn data_source_counts(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<DataSourceCounts, String> {
|
||||||
|
let (fixed,): (i64,) =
|
||||||
|
sqlx::query_as("SELECT COUNT(*) FROM assets WHERE asset_type = 'fixed' AND deleted = 0")
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let (range,): (i64,) =
|
||||||
|
sqlx::query_as("SELECT COUNT(*) FROM assets WHERE asset_type = 'range' AND deleted = 0")
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let (roads,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM roads")
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let (scope,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scope_polygons")
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(DataSourceCounts {
|
||||||
|
fixed_assets: fixed,
|
||||||
|
range_assets: range,
|
||||||
|
osm_roads: roads,
|
||||||
|
scope_features: scope,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_assets_by_type(
|
||||||
|
asset_type: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
if asset_type != "fixed" && asset_type != "range" {
|
||||||
|
return Err("asset_type must be 'fixed' or 'range'".into());
|
||||||
|
}
|
||||||
|
let res = sqlx::query("DELETE FROM assets WHERE asset_type = ?")
|
||||||
|
.bind(&asset_type)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(res.rows_affected() as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_osm_roads(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let res = sqlx::query("DELETE FROM roads")
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(res.rows_affected() as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_scope_polygons(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let res = sqlx::query("DELETE FROM scope_polygons")
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
// Reset derived in_scope on every asset that didn't have a manual override.
|
||||||
|
sqlx::query("UPDATE assets SET in_scope = NULL WHERE manual_scope IS NULL")
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(res.rows_affected() as usize)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_recent_actions(
|
pub async fn list_recent_actions(
|
||||||
limit: i64,
|
limit: i64,
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ mod scope;
|
|||||||
use commands::{
|
use commands::{
|
||||||
delete_asset, delete_assets_by_video, get_assets_in_bbox, get_osm_roads,
|
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_fixed_assets, import_kml_scope, import_osm_geojson,
|
||||||
import_range_assets, list_assets, 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, change_assets_side, clear_link, clear_manual_scope,
|
add_class, auto_link_in_polygon, bulk_translate, clear_assets_by_type,
|
||||||
create_osm_road, create_user, set_link,
|
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,
|
||||||
delete_roads_by_highway, export_osm_roads, find_duplicate_clusters,
|
delete_roads_by_highway, export_osm_roads, find_duplicate_clusters,
|
||||||
flip_road_direction, generate_osm_for_bbox, list_road_classes, merge_roads_by_name,
|
flip_road_direction, generate_osm_for_bbox, list_road_classes, merge_roads_by_name,
|
||||||
delete_assets_bulk, delete_osm_road, delete_user, export_assets,
|
delete_assets_bulk, delete_osm_road, delete_user, export_assets,
|
||||||
@@ -47,6 +48,7 @@ pub fn run() {
|
|||||||
get_assets_in_bbox,
|
get_assets_in_bbox,
|
||||||
update_asset_position,
|
update_asset_position,
|
||||||
import_kml_scope,
|
import_kml_scope,
|
||||||
|
read_video_polylines_json,
|
||||||
get_scope_polygons,
|
get_scope_polygons,
|
||||||
delete_asset,
|
delete_asset,
|
||||||
delete_assets_by_video,
|
delete_assets_by_video,
|
||||||
@@ -62,6 +64,8 @@ pub fn run() {
|
|||||||
snap_assets_in_bbox,
|
snap_assets_in_bbox,
|
||||||
update_osm_road,
|
update_osm_road,
|
||||||
create_osm_road,
|
create_osm_road,
|
||||||
|
delete_road_vertices,
|
||||||
|
simplify_road,
|
||||||
delete_osm_road,
|
delete_osm_road,
|
||||||
list_asset_names,
|
list_asset_names,
|
||||||
export_assets,
|
export_assets,
|
||||||
@@ -88,6 +92,10 @@ pub fn run() {
|
|||||||
change_assets_side,
|
change_assets_side,
|
||||||
auto_link_in_polygon,
|
auto_link_in_polygon,
|
||||||
bulk_translate,
|
bulk_translate,
|
||||||
|
clear_assets_by_type,
|
||||||
|
clear_osm_roads,
|
||||||
|
clear_scope_polygons,
|
||||||
|
data_source_counts,
|
||||||
set_link,
|
set_link,
|
||||||
clear_link
|
clear_link
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -164,7 +164,6 @@ pub async fn apply_scope_to_assets(
|
|||||||
geoms: &[ScopeGeom],
|
geoms: &[ScopeGeom],
|
||||||
) -> Result<usize> {
|
) -> Result<usize> {
|
||||||
if geoms.is_empty() {
|
if geoms.is_empty() {
|
||||||
// Reset only assets that don't have a manual override.
|
|
||||||
sqlx::query("UPDATE assets SET in_scope = NULL WHERE manual_scope IS NULL")
|
sqlx::query("UPDATE assets SET in_scope = NULL WHERE manual_scope IS NULL")
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
11
src/App.css
11
src/App.css
@@ -408,6 +408,17 @@ body,
|
|||||||
.vertex-pin.v-end { background: #e74c3c; }
|
.vertex-pin.v-end { background: #e74c3c; }
|
||||||
.vertex-pin.v-fixed { background: #1abc9c; }
|
.vertex-pin.v-fixed { background: #1abc9c; }
|
||||||
.vertex-pin.v-osm { background: #ffd700; color: #222; width: 14px; height: 14px; font-size: 10px; }
|
.vertex-pin.v-osm { background: #ffd700; color: #222; width: 14px; height: 14px; font-size: 10px; }
|
||||||
|
.vertex-pin.v-osm-marked {
|
||||||
|
background: #e74c3c;
|
||||||
|
color: #fff;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 0 0 1px #c0392b;
|
||||||
|
}
|
||||||
.vertex-pin.v-osm-ghost {
|
.vertex-pin.v-osm-ghost {
|
||||||
background: rgba(255, 215, 0, 0.45);
|
background: rgba(255, 215, 0, 0.45);
|
||||||
color: #222;
|
color: #222;
|
||||||
|
|||||||
624
src/App.tsx
624
src/App.tsx
@@ -118,6 +118,12 @@ function AppShell({
|
|||||||
const [compare, setCompare] = useState<boolean>(false);
|
const [compare, setCompare] = useState<boolean>(false);
|
||||||
const [assets, setAssets] = useState<Asset[]>([]);
|
const [assets, setAssets] = useState<Asset[]>([]);
|
||||||
const [scopePolygons, setScopePolygons] = useState<ScopePolygon[]>([]);
|
const [scopePolygons, setScopePolygons] = useState<ScopePolygon[]>([]);
|
||||||
|
const [dataCounts, setDataCounts] = useState<{
|
||||||
|
fixed_assets: number;
|
||||||
|
range_assets: number;
|
||||||
|
osm_roads: number;
|
||||||
|
scope_features: number;
|
||||||
|
}>({ fixed_assets: 0, range_assets: 0, osm_roads: 0, scope_features: 0 });
|
||||||
const [recentActions, setRecentActions] = useState<ActionRow[]>([]);
|
const [recentActions, setRecentActions] = useState<ActionRow[]>([]);
|
||||||
const [status, setStatus] = useState<string>("");
|
const [status, setStatus] = useState<string>("");
|
||||||
const [busy, setBusy] = useState<boolean>(false);
|
const [busy, setBusy] = useState<boolean>(false);
|
||||||
@@ -143,6 +149,16 @@ function AppShell({
|
|||||||
features: [],
|
features: [],
|
||||||
});
|
});
|
||||||
const [metadataTrack, setMetadataTrack] = useState<Array<[number, number]>>([]);
|
const [metadataTrack, setMetadataTrack] = useState<Array<[number, number]>>([]);
|
||||||
|
// Per-video metadata polylines loaded from a multi-video JSON. When set,
|
||||||
|
// snap-to-road and direction inference look up each asset's track by video.
|
||||||
|
// The single-track `metadataTrack` is still used as a fallback (e.g. when
|
||||||
|
// only one video is loaded via the legacy importer).
|
||||||
|
const [metadataByVideo, setMetadataByVideo] = useState<
|
||||||
|
Record<string, Array<[number, number]>>
|
||||||
|
>({});
|
||||||
|
const [selectedMetadataVideo, setSelectedMetadataVideo] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
const [layerVisibility, setLayerVisibility] = useState<LayerVisibility>({
|
const [layerVisibility, setLayerVisibility] = useState<LayerVisibility>({
|
||||||
scope: true,
|
scope: true,
|
||||||
metadata: true,
|
metadata: true,
|
||||||
@@ -176,9 +192,21 @@ function AppShell({
|
|||||||
const [namesExpanded, setNamesExpanded] = useState<boolean>(true);
|
const [namesExpanded, setNamesExpanded] = useState<boolean>(true);
|
||||||
const [filterPanelOpen, setFilterPanelOpen] = useState<boolean>(true);
|
const [filterPanelOpen, setFilterPanelOpen] = useState<boolean>(true);
|
||||||
const [videoSearch, setVideoSearch] = useState<string>("");
|
const [videoSearch, setVideoSearch] = useState<string>("");
|
||||||
|
const [gotoQuery, setGotoQuery] = useState<string>("");
|
||||||
|
const [gotoPin, setGotoPin] = useState<{ lat: number; lng: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [markedVertices, setMarkedVertices] = useState<Set<number>>(new Set());
|
||||||
|
const [simplifyTolM, setSimplifyTolM] = useState<number>(2);
|
||||||
const [allAssetNames, setAllAssetNames] = useState<string[]>([]);
|
const [allAssetNames, setAllAssetNames] = useState<string[]>([]);
|
||||||
const [osmEditMode, setOsmEditMode] = useState<boolean>(false);
|
const [osmEditMode, setOsmEditMode] = useState<boolean>(false);
|
||||||
const [editingRoadId, setEditingRoadId] = useState<number | null>(null);
|
const [editingRoadId, setEditingRoadId] = useState<number | null>(null);
|
||||||
|
// Reset the marked-vertex set whenever the user switches roads or leaves
|
||||||
|
// OSM-edit mode. Indices are road-relative, so they're meaningless across
|
||||||
|
// roads.
|
||||||
|
useEffect(() => {
|
||||||
|
setMarkedVertices(new Set());
|
||||||
|
}, [editingRoadId, osmEditMode]);
|
||||||
const [drawRoadMode, setDrawRoadMode] = useState<boolean>(false);
|
const [drawRoadMode, setDrawRoadMode] = useState<boolean>(false);
|
||||||
const [drawRoadCoords, setDrawRoadCoords] = useState<
|
const [drawRoadCoords, setDrawRoadCoords] = useState<
|
||||||
Array<[number, number]>
|
Array<[number, number]>
|
||||||
@@ -396,8 +424,12 @@ function AppShell({
|
|||||||
names.add(a.asset_name);
|
names.add(a.asset_name);
|
||||||
if (vids.size > 1 && names.size > 1) break; // early-out: result will be "video"
|
if (vids.size > 1 && names.size > 1) break; // early-out: result will be "video"
|
||||||
}
|
}
|
||||||
|
// Single video visible → fall back to asset / side coloring so the user
|
||||||
|
// can tell individual assets apart instead of seeing one solid color.
|
||||||
if (vids.size <= 1 && names.size === 1) return "side";
|
if (vids.size <= 1 && names.size === 1) return "side";
|
||||||
if (vids.size <= 1) return "asset";
|
if (vids.size <= 1) return "asset";
|
||||||
|
// Multiple videos visible → match each asset's color to its metadata
|
||||||
|
// polyline (when loaded) by coloring by video.
|
||||||
return "video";
|
return "video";
|
||||||
}, [filteredAssets]);
|
}, [filteredAssets]);
|
||||||
|
|
||||||
@@ -499,6 +531,29 @@ function AppShell({
|
|||||||
// ESC: deselect editing road (or cancel a draw-in-progress).
|
// ESC: deselect editing road (or cancel a draw-in-progress).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
(e.key === "Delete" || e.key === "Backspace") &&
|
||||||
|
osmEditMode &&
|
||||||
|
editingRoadId !== null &&
|
||||||
|
markedVertices.size > 0
|
||||||
|
) {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
const tag = target?.tagName;
|
||||||
|
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
const idx = Array.from(markedVertices);
|
||||||
|
const roadId = editingRoadId;
|
||||||
|
invoke<number>("delete_road_vertices", { id: roadId, indices: idx })
|
||||||
|
.then((n) => {
|
||||||
|
setStatus(`Removed ${n} vertex(s) from road #${roadId}`);
|
||||||
|
setMarkedVertices(new Set());
|
||||||
|
return refreshOsmRoads();
|
||||||
|
})
|
||||||
|
.catch((err) => setStatus(`Vertex delete failed: ${err}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ((e.key === "Delete" || e.key === "Backspace") && selectedLink) {
|
if ((e.key === "Delete" || e.key === "Backspace") && selectedLink) {
|
||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
const tag = target?.tagName;
|
const tag = target?.tagName;
|
||||||
@@ -523,6 +578,18 @@ function AppShell({
|
|||||||
setStatus("Lasso cancelled.");
|
setStatus("Lasso cancelled.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (selectedMetadataVideo !== null) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedMetadataVideo(null);
|
||||||
|
setStatus("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (gotoPin !== null) {
|
||||||
|
e.preventDefault();
|
||||||
|
setGotoPin(null);
|
||||||
|
setStatus("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (linkPickMode) {
|
if (linkPickMode) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLinkPickMode(false);
|
setLinkPickMode(false);
|
||||||
@@ -541,7 +608,7 @@ function AppShell({
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", handler);
|
window.addEventListener("keydown", handler);
|
||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [drawRoadMode, editingRoadId, linkPickMode, rightClickFirstId, lassoMode, selectedLink]);
|
}, [drawRoadMode, editingRoadId, linkPickMode, rightClickFirstId, lassoMode, selectedLink, selectedMetadataVideo, gotoPin, osmEditMode, markedVertices]);
|
||||||
|
|
||||||
// Image prefetch: when an asset is selected, warm the browser cache for its
|
// Image prefetch: when an asset is selected, warm the browser cache for its
|
||||||
// immediate neighbours so [/] navigation feels instant.
|
// immediate neighbours so [/] navigation feels instant.
|
||||||
@@ -887,11 +954,116 @@ function AppShell({
|
|||||||
refreshAssetNames(),
|
refreshAssetNames(),
|
||||||
refreshVideoNames(),
|
refreshVideoNames(),
|
||||||
refreshClasses(),
|
refreshClasses(),
|
||||||
|
refreshDataCounts(),
|
||||||
];
|
];
|
||||||
if (boundsRef.current) tasks.push(fetchBboxAssets(boundsRef.current));
|
if (boundsRef.current) tasks.push(fetchBboxAssets(boundsRef.current));
|
||||||
await Promise.all(tasks);
|
await Promise.all(tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fly the map to a specific video (uses any combination of metadata polyline
|
||||||
|
// + asset bboxes, whichever exists).
|
||||||
|
function flyToVideo(name: string) {
|
||||||
|
let mnLat = Infinity,
|
||||||
|
mxLat = -Infinity,
|
||||||
|
mnLng = Infinity,
|
||||||
|
mxLng = -Infinity;
|
||||||
|
const track = metadataByVideo[name];
|
||||||
|
if (track && track.length > 0) {
|
||||||
|
for (const [lng, lat] of track) {
|
||||||
|
if (lng < mnLng) mnLng = lng;
|
||||||
|
if (lng > mxLng) mxLng = lng;
|
||||||
|
if (lat < mnLat) mnLat = lat;
|
||||||
|
if (lat > mxLat) mxLat = lat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const a of assets) {
|
||||||
|
if (a.deleted !== 0) continue;
|
||||||
|
if (displayVideo(a.video_name) !== name) continue;
|
||||||
|
const lats = [a.lat, a.start_lat, a.end_lat].filter(
|
||||||
|
(v): v is number => v !== null,
|
||||||
|
);
|
||||||
|
const lngs = [a.lng, a.start_lng, a.end_lng].filter(
|
||||||
|
(v): v is number => v !== null,
|
||||||
|
);
|
||||||
|
for (const v of lats) {
|
||||||
|
if (v < mnLat) mnLat = v;
|
||||||
|
if (v > mxLat) mxLat = v;
|
||||||
|
}
|
||||||
|
for (const v of lngs) {
|
||||||
|
if (v < mnLng) mnLng = v;
|
||||||
|
if (v > mxLng) mxLng = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(mnLat)) {
|
||||||
|
setStatus(`No location for "${name}" (load metadata or assets first).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setViewport({
|
||||||
|
center: [(mnLng + mxLng) / 2, (mnLat + mxLat) / 2],
|
||||||
|
zoom: 14,
|
||||||
|
});
|
||||||
|
// If the video has a metadata track, select it so the user sees direction
|
||||||
|
// arrows + S/E markers + name badge — same effect as tapping the line.
|
||||||
|
if (metadataByVideo[name]) {
|
||||||
|
setSelectedMetadataVideo(name);
|
||||||
|
}
|
||||||
|
setStatus(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGoto() {
|
||||||
|
const q = gotoQuery.trim();
|
||||||
|
if (q === "") return;
|
||||||
|
// Try lat,lng first.
|
||||||
|
const m = q.match(/^\s*(-?\d+(?:\.\d+)?)\s*[, ]\s*(-?\d+(?:\.\d+)?)\s*$/);
|
||||||
|
if (m) {
|
||||||
|
const lat = parseFloat(m[1]);
|
||||||
|
const lng = parseFloat(m[2]);
|
||||||
|
if (
|
||||||
|
Number.isFinite(lat) &&
|
||||||
|
Number.isFinite(lng) &&
|
||||||
|
lat >= -90 &&
|
||||||
|
lat <= 90 &&
|
||||||
|
lng >= -180 &&
|
||||||
|
lng <= 180
|
||||||
|
) {
|
||||||
|
setViewport({ center: [lng, lat], zoom: 18 });
|
||||||
|
setGotoPin({ lat, lng });
|
||||||
|
setStatus(`📍 ${lat.toFixed(6)}, ${lng.toFixed(6)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(`Out-of-range lat/lng: ${q}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Else treat as a video name — exact, then prefix, then substring (case-insensitive).
|
||||||
|
const videos = Object.keys(metadataByVideo).concat(allVideoNames);
|
||||||
|
const lower = q.toLowerCase();
|
||||||
|
const exact = videos.find((v) => v === q);
|
||||||
|
const prefix = videos.find((v) => v.toLowerCase().startsWith(lower));
|
||||||
|
const sub = videos.find((v) => v.toLowerCase().includes(lower));
|
||||||
|
const hit = exact ?? prefix ?? sub;
|
||||||
|
if (hit) {
|
||||||
|
flyToVideo(hit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(
|
||||||
|
`No match for "${q}". Use "lat,lng" (e.g. 17.398, 78.347) or a video name.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDataCounts() {
|
||||||
|
try {
|
||||||
|
const c = await invoke<{
|
||||||
|
fixed_assets: number;
|
||||||
|
range_assets: number;
|
||||||
|
osm_roads: number;
|
||||||
|
scope_features: number;
|
||||||
|
}>("data_source_counts");
|
||||||
|
setDataCounts(c);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshScopePolygons().catch((e) =>
|
refreshScopePolygons().catch((e) =>
|
||||||
setStatus(`Scope load failed: ${e}`),
|
setStatus(`Scope load failed: ${e}`),
|
||||||
@@ -901,6 +1073,7 @@ function AppShell({
|
|||||||
refreshAssetNames().catch(() => {});
|
refreshAssetNames().catch(() => {});
|
||||||
refreshVideoNames().catch(() => {});
|
refreshVideoNames().catch(() => {});
|
||||||
refreshClasses().catch(() => {});
|
refreshClasses().catch(() => {});
|
||||||
|
refreshDataCounts().catch(() => {});
|
||||||
invoke<Asset[]>("list_assets")
|
invoke<Asset[]>("list_assets")
|
||||||
.then((list) => {
|
.then((list) => {
|
||||||
if (list.length > 0) {
|
if (list.length > 0) {
|
||||||
@@ -1205,7 +1378,12 @@ function AppShell({
|
|||||||
maxDistanceM: 50,
|
maxDistanceM: 50,
|
||||||
offsetM: roadOffsetMeters,
|
offsetM: roadOffsetMeters,
|
||||||
centerlineNames: Array.from(centerlineNames),
|
centerlineNames: Array.from(centerlineNames),
|
||||||
metadataTrack: metadataTrack.length >= 2 ? metadataTrack : null,
|
metadataTrack:
|
||||||
|
metadataByVideo[name]?.length >= 2
|
||||||
|
? metadataByVideo[name]
|
||||||
|
: metadataTrack.length >= 2
|
||||||
|
? metadataTrack
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
setStatus(
|
setStatus(
|
||||||
`Snap by video: ${res.snapped} moved, ${res.skipped_far} too far, ${res.skipped_no_geom} skipped`,
|
`Snap by video: ${res.snapped} moved, ${res.skipped_far} too far, ${res.skipped_no_geom} skipped`,
|
||||||
@@ -1715,7 +1893,15 @@ function AppShell({
|
|||||||
maxDistanceM: 50,
|
maxDistanceM: 50,
|
||||||
offsetM: roadOffsetMeters,
|
offsetM: roadOffsetMeters,
|
||||||
centerlineNames: Array.from(centerlineNames),
|
centerlineNames: Array.from(centerlineNames),
|
||||||
metadataTrack: metadataTrack.length >= 2 ? metadataTrack : null,
|
metadataTrack: (() => {
|
||||||
|
// If only one video is selected in the filters, use that video's
|
||||||
|
// track. Otherwise fall back to the concatenated single track.
|
||||||
|
const sel = Array.from(filters.videos);
|
||||||
|
if (sel.length === 1 && metadataByVideo[sel[0]]?.length >= 2) {
|
||||||
|
return metadataByVideo[sel[0]];
|
||||||
|
}
|
||||||
|
return metadataTrack.length >= 2 ? metadataTrack : null;
|
||||||
|
})(),
|
||||||
});
|
});
|
||||||
setStatus(
|
setStatus(
|
||||||
`Bulk snap: ${res.snapped} moved, ${res.skipped_far} too far, ${res.skipped_no_geom} skipped`,
|
`Bulk snap: ${res.snapped} moved, ${res.skipped_far} too far, ${res.skipped_no_geom} skipped`,
|
||||||
@@ -1733,11 +1919,18 @@ function AppShell({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const useCenter = centerlineNames.has(selectedAsset.asset_name);
|
const useCenter = centerlineNames.has(selectedAsset.asset_name);
|
||||||
|
const perVideo = metadataByVideo[selectedAsset.video_name];
|
||||||
|
const trackForAsset =
|
||||||
|
perVideo && perVideo.length >= 2
|
||||||
|
? perVideo
|
||||||
|
: metadataTrack.length >= 2
|
||||||
|
? metadataTrack
|
||||||
|
: null;
|
||||||
const [lat, lng] = await invoke<[number, number]>("snap_to_road", {
|
const [lat, lng] = await invoke<[number, number]>("snap_to_road", {
|
||||||
assetId: selectedAsset.id,
|
assetId: selectedAsset.id,
|
||||||
maxDistanceM: 50,
|
maxDistanceM: 50,
|
||||||
offsetM: useCenter ? 0 : roadOffsetMeters,
|
offsetM: useCenter ? 0 : roadOffsetMeters,
|
||||||
metadataTrack: metadataTrack.length >= 2 ? metadataTrack : null,
|
metadataTrack: trackForAsset,
|
||||||
});
|
});
|
||||||
setStatus(
|
setStatus(
|
||||||
`Snapped to road @ ${lat.toFixed(6)}, ${lng.toFixed(6)}`,
|
`Snapped to road @ ${lat.toFixed(6)}, ${lng.toFixed(6)}`,
|
||||||
@@ -1778,6 +1971,58 @@ function AppShell({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleImportVideoMetadata() {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setStatus("Picking per-video metadata JSON…");
|
||||||
|
try {
|
||||||
|
const picked = await open({
|
||||||
|
multiple: false,
|
||||||
|
directory: false,
|
||||||
|
filters: [{ name: "Per-video metadata JSON", extensions: ["json"] }],
|
||||||
|
});
|
||||||
|
if (typeof picked !== "string") {
|
||||||
|
setStatus("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("Parsing video polylines…");
|
||||||
|
const map = await invoke<Record<string, Array<[number, number]>>>(
|
||||||
|
"read_video_polylines_json",
|
||||||
|
{ path: picked },
|
||||||
|
);
|
||||||
|
setMetadataByVideo(map);
|
||||||
|
const keys = Object.keys(map);
|
||||||
|
// Concatenated fallback for callers that don't yet pass a video.
|
||||||
|
const flat: Array<[number, number]> = [];
|
||||||
|
for (const k of keys) flat.push(...map[k]);
|
||||||
|
setMetadataTrack(flat);
|
||||||
|
// Recenter the map on the metadata bbox so the user can actually see
|
||||||
|
// what they just imported (otherwise the polylines may sit outside the
|
||||||
|
// current viewport and look "missing").
|
||||||
|
if (flat.length > 0) {
|
||||||
|
let minLng = Infinity, maxLng = -Infinity;
|
||||||
|
let minLat = Infinity, maxLat = -Infinity;
|
||||||
|
for (const [lng, lat] of flat) {
|
||||||
|
if (lng < minLng) minLng = lng;
|
||||||
|
if (lng > maxLng) maxLng = lng;
|
||||||
|
if (lat < minLat) minLat = lat;
|
||||||
|
if (lat > maxLat) maxLat = lat;
|
||||||
|
}
|
||||||
|
setViewport({
|
||||||
|
center: [(minLng + maxLng) / 2, (minLat + maxLat) / 2],
|
||||||
|
zoom: 12,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setStatus(
|
||||||
|
`Loaded metadata for ${keys.length} video(s). Toggle "Metadata polyline" in Layers if it's off. Snap & direction now use each asset's matching video track.`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(`Video metadata import failed: ${e}`);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleImport(kind: "fixed" | "range") {
|
async function handleImport(kind: "fixed" | "range") {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -1838,6 +2083,25 @@ function AppShell({
|
|||||||
scopePolygons={scopePolygons}
|
scopePolygons={scopePolygons}
|
||||||
osmRoads={osmRoads}
|
osmRoads={osmRoads}
|
||||||
metadataTrack={metadataTrack}
|
metadataTrack={metadataTrack}
|
||||||
|
metadataTracks={metadataByVideo}
|
||||||
|
selectedMetadataVideo={selectedMetadataVideo}
|
||||||
|
gotoPin={gotoPin}
|
||||||
|
markedVertices={markedVertices}
|
||||||
|
onVertexShiftClick={(idx) => {
|
||||||
|
setMarkedVertices((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(idx)) next.delete(idx);
|
||||||
|
else next.add(idx);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onMetadataLineClick={(name) => {
|
||||||
|
setSelectedMetadataVideo((cur) => {
|
||||||
|
const next = cur === name ? null : name;
|
||||||
|
setStatus(next === null ? "" : name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
layerVisibility={layerVisibility}
|
layerVisibility={layerVisibility}
|
||||||
selectedAsset={selectedAsset}
|
selectedAsset={selectedAsset}
|
||||||
onSelect={(a) => {
|
onSelect={(a) => {
|
||||||
@@ -1928,6 +2192,25 @@ function AppShell({
|
|||||||
scopePolygons={scopePolygons}
|
scopePolygons={scopePolygons}
|
||||||
osmRoads={osmRoads}
|
osmRoads={osmRoads}
|
||||||
metadataTrack={metadataTrack}
|
metadataTrack={metadataTrack}
|
||||||
|
metadataTracks={metadataByVideo}
|
||||||
|
selectedMetadataVideo={selectedMetadataVideo}
|
||||||
|
gotoPin={gotoPin}
|
||||||
|
markedVertices={markedVertices}
|
||||||
|
onVertexShiftClick={(idx) => {
|
||||||
|
setMarkedVertices((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(idx)) next.delete(idx);
|
||||||
|
else next.add(idx);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onMetadataLineClick={(name) => {
|
||||||
|
setSelectedMetadataVideo((cur) => {
|
||||||
|
const next = cur === name ? null : name;
|
||||||
|
setStatus(next === null ? "" : name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
layerVisibility={layerVisibility}
|
layerVisibility={layerVisibility}
|
||||||
selectedAsset={selectedAsset}
|
selectedAsset={selectedAsset}
|
||||||
onSelect={(a) => {
|
onSelect={(a) => {
|
||||||
@@ -2148,7 +2431,12 @@ function AppShell({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ maxHeight: 240, overflowY: "auto" }}>
|
<div style={{ maxHeight: 240, overflowY: "auto" }}>
|
||||||
{allVideoNames
|
{Array.from(
|
||||||
|
new Set(
|
||||||
|
allVideoNames.concat(Object.keys(metadataByVideo)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.sort()
|
||||||
.filter((v) =>
|
.filter((v) =>
|
||||||
videoSearch.trim() === ""
|
videoSearch.trim() === ""
|
||||||
? true
|
? true
|
||||||
@@ -2176,6 +2464,25 @@ function AppShell({
|
|||||||
{v}
|
{v}
|
||||||
</span>
|
</span>
|
||||||
<span className="count">{n}</span>
|
<span className="count">{n}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
flyToVideo(v);
|
||||||
|
}}
|
||||||
|
title={`Fly to ${v}`}
|
||||||
|
style={{
|
||||||
|
marginLeft: 4,
|
||||||
|
padding: "0 6px",
|
||||||
|
fontSize: 11,
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: 3,
|
||||||
|
background: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⤢
|
||||||
|
</button>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2378,9 +2685,22 @@ function AppShell({
|
|||||||
handleImportScope();
|
handleImportScope();
|
||||||
}}
|
}}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
title="Import a KML scope polygon or polyline (geometric only)."
|
||||||
>
|
>
|
||||||
KML scope…
|
KML scope…
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="primary-btn"
|
||||||
|
style={{ background: "#9b59b6" }}
|
||||||
|
onClick={() => {
|
||||||
|
setImportsOpen(false);
|
||||||
|
handleImportVideoMetadata();
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
title="Import per-video metadata polylines ({video_name: [[[lat,lng],...]]}). Snap & direction inference will use each asset's matching video track."
|
||||||
|
>
|
||||||
|
Per-video metadata (JSON)…
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="primary-btn"
|
className="primary-btn"
|
||||||
style={{ background: "#34495e" }}
|
style={{ background: "#34495e" }}
|
||||||
@@ -2691,6 +3011,45 @@ function AppShell({
|
|||||||
<span>Compare basemaps (side-by-side)</span>
|
<span>Compare basemaps (side-by-side)</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<h3 style={{ marginTop: 16 }}>Go to</h3>
|
||||||
|
<div style={{ display: "flex", gap: 4 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="lat, lng — or video name"
|
||||||
|
value={gotoQuery}
|
||||||
|
onChange={(e) => setGotoQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleGoto();
|
||||||
|
}}
|
||||||
|
list="goto-video-options"
|
||||||
|
autoComplete="off"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: 4,
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: 3,
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<datalist id="goto-video-options">
|
||||||
|
{Array.from(
|
||||||
|
new Set(Object.keys(metadataByVideo).concat(allVideoNames)),
|
||||||
|
)
|
||||||
|
.sort()
|
||||||
|
.map((v) => (
|
||||||
|
<option key={v} value={v} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
<button
|
||||||
|
className="primary-btn"
|
||||||
|
style={{ padding: "2px 8px", fontSize: 11 }}
|
||||||
|
onClick={handleGoto}
|
||||||
|
disabled={busy || gotoQuery.trim() === ""}
|
||||||
|
>
|
||||||
|
Go
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 style={{ marginTop: 16 }}>Data</h3>
|
<h3 style={{ marginTop: 16 }}>Data</h3>
|
||||||
<button
|
<button
|
||||||
className="primary-btn"
|
className="primary-btn"
|
||||||
@@ -2699,6 +3058,166 @@ function AppShell({
|
|||||||
>
|
>
|
||||||
Import…
|
Import…
|
||||||
</button>
|
</button>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
padding: 6,
|
||||||
|
background: "#f6f8fa",
|
||||||
|
border: "1px solid #d0d7de",
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 4 }}>Loaded data</div>
|
||||||
|
{([
|
||||||
|
{
|
||||||
|
label: "Fixed assets",
|
||||||
|
count: dataCounts.fixed_assets,
|
||||||
|
onReplace: () => {
|
||||||
|
setImportsOpen(false);
|
||||||
|
handleImport("fixed");
|
||||||
|
},
|
||||||
|
onClear: async () => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Permanently delete all ${dataCounts.fixed_assets} fixed asset(s)?`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
const n = await invoke<number>("clear_assets_by_type", {
|
||||||
|
assetType: "fixed",
|
||||||
|
});
|
||||||
|
setStatus(`Cleared ${n} fixed asset(s)`);
|
||||||
|
await refreshAfterMutation();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(`Clear failed: ${e}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Range assets",
|
||||||
|
count: dataCounts.range_assets,
|
||||||
|
onReplace: () => {
|
||||||
|
setImportsOpen(false);
|
||||||
|
handleImport("range");
|
||||||
|
},
|
||||||
|
onClear: async () => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Permanently delete all ${dataCounts.range_assets} range asset(s)?`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
const n = await invoke<number>("clear_assets_by_type", {
|
||||||
|
assetType: "range",
|
||||||
|
});
|
||||||
|
setStatus(`Cleared ${n} range asset(s)`);
|
||||||
|
await refreshAfterMutation();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(`Clear failed: ${e}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "OSM roads",
|
||||||
|
count: dataCounts.osm_roads,
|
||||||
|
onReplace: () => handleImportOsm(),
|
||||||
|
onClear: async () => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Permanently delete all ${dataCounts.osm_roads} OSM road(s)?`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
const n = await invoke<number>("clear_osm_roads");
|
||||||
|
setStatus(`Cleared ${n} OSM road(s)`);
|
||||||
|
await refreshOsmRoads();
|
||||||
|
await refreshDataCounts();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(`Clear failed: ${e}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "KML scope",
|
||||||
|
count: dataCounts.scope_features,
|
||||||
|
onReplace: () => handleImportScope(),
|
||||||
|
onClear: async () => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Permanently delete all ${dataCounts.scope_features} scope feature(s)?`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
const n = await invoke<number>("clear_scope_polygons");
|
||||||
|
setStatus(`Cleared ${n} scope feature(s)`);
|
||||||
|
await refreshScopePolygons();
|
||||||
|
await refreshAfterMutation();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(`Clear failed: ${e}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Per-video metadata",
|
||||||
|
count: Object.keys(metadataByVideo).length,
|
||||||
|
onReplace: () => handleImportVideoMetadata(),
|
||||||
|
onClear: () => {
|
||||||
|
setMetadataByVideo({});
|
||||||
|
setMetadataTrack([]);
|
||||||
|
setSelectedMetadataVideo(null);
|
||||||
|
setStatus("Cleared per-video metadata");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as const).map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.label}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
marginBottom: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 1 }}>
|
||||||
|
{row.label}:{" "}
|
||||||
|
<strong>
|
||||||
|
{row.count > 0 ? row.count.toLocaleString() : "—"}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="primary-btn"
|
||||||
|
style={{
|
||||||
|
padding: "2px 6px",
|
||||||
|
fontSize: 10,
|
||||||
|
background: "#0969da",
|
||||||
|
}}
|
||||||
|
onClick={row.onReplace}
|
||||||
|
disabled={busy}
|
||||||
|
title={row.count > 0 ? "Replace with a new file" : "Add"}
|
||||||
|
>
|
||||||
|
{row.count > 0 ? "Replace…" : "Add…"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="primary-btn"
|
||||||
|
style={{
|
||||||
|
padding: "2px 6px",
|
||||||
|
fontSize: 10,
|
||||||
|
background: "#cf222e",
|
||||||
|
}}
|
||||||
|
onClick={row.onClear}
|
||||||
|
disabled={busy || row.count === 0}
|
||||||
|
title="Permanently remove this data source"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="primary-btn"
|
className="primary-btn"
|
||||||
style={{
|
style={{
|
||||||
@@ -2733,6 +3252,101 @@ function AppShell({
|
|||||||
>
|
>
|
||||||
Flip direction
|
Flip direction
|
||||||
</button>
|
</button>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
padding: 6,
|
||||||
|
background: "#fff8e1",
|
||||||
|
border: "1px solid #f1c40f",
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||||
|
Bulk vertex tools
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
Shift-click vertices to mark (red ✕). Press{" "}
|
||||||
|
<strong>Del</strong> to remove all marked.
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
Currently marked:{" "}
|
||||||
|
<strong>{markedVertices.size}</strong>
|
||||||
|
{markedVertices.size > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
marginLeft: 6,
|
||||||
|
padding: "0 6px",
|
||||||
|
fontSize: 10,
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: 3,
|
||||||
|
background: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => setMarkedVertices(new Set())}
|
||||||
|
>
|
||||||
|
Clear marks
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
marginTop: 6,
|
||||||
|
}}
|
||||||
|
title="Douglas-Peucker tolerance in metres. Higher = more aggressive simplification."
|
||||||
|
>
|
||||||
|
<span>Simplify (m):</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0.5}
|
||||||
|
max={20}
|
||||||
|
step={0.5}
|
||||||
|
value={simplifyTolM}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSimplifyTolM(parseFloat(e.target.value))
|
||||||
|
}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<span style={{ width: 36, textAlign: "right" }}>
|
||||||
|
{simplifyTolM} m
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="primary-btn"
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
width: "100%",
|
||||||
|
background: "#16a085",
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
onClick={async () => {
|
||||||
|
if (editingRoadId === null) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const [before, after] = await invoke<[number, number]>(
|
||||||
|
"simplify_road",
|
||||||
|
{ id: editingRoadId, toleranceM: simplifyTolM },
|
||||||
|
);
|
||||||
|
setStatus(
|
||||||
|
`Road #${editingRoadId}: ${before} → ${after} vertices (${before - after} removed)`,
|
||||||
|
);
|
||||||
|
setMarkedVertices(new Set());
|
||||||
|
await refreshOsmRoads();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(`Simplify failed: ${e}`);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Simplify this road
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="primary-btn"
|
className="primary-btn"
|
||||||
style={{ marginTop: 6, background: "#7f8c8d" }}
|
style={{ marginTop: 6, background: "#7f8c8d" }}
|
||||||
|
|||||||
359
src/MapView.tsx
359
src/MapView.tsx
@@ -7,7 +7,7 @@ import "maplibre-gl/dist/maplibre-gl.css";
|
|||||||
// === ['MapboxOverlay']`). Remove this directive if a future TS/deck.gl release surfaces
|
// === ['MapboxOverlay']`). Remove this directive if a future TS/deck.gl release surfaces
|
||||||
// the named export.
|
// the named export.
|
||||||
import { MapboxOverlay } from "@deck.gl/mapbox";
|
import { MapboxOverlay } from "@deck.gl/mapbox";
|
||||||
import { ScatterplotLayer, PathLayer, GeoJsonLayer, TextLayer } from "@deck.gl/layers";
|
import { ScatterplotLayer, PathLayer, GeoJsonLayer, TextLayer, IconLayer } from "@deck.gl/layers";
|
||||||
import { PathStyleExtension } from "@deck.gl/extensions";
|
import { PathStyleExtension } from "@deck.gl/extensions";
|
||||||
import { basemapById } from "./basemaps";
|
import { basemapById } from "./basemaps";
|
||||||
import { videoColorRGB } from "./colors";
|
import { videoColorRGB } from "./colors";
|
||||||
@@ -54,11 +54,18 @@ type Props = {
|
|||||||
scopePolygons?: ScopePolygon[];
|
scopePolygons?: ScopePolygon[];
|
||||||
osmRoads?: OsmRoadFC;
|
osmRoads?: OsmRoadFC;
|
||||||
metadataTrack?: Array<[number, number]>;
|
metadataTrack?: Array<[number, number]>;
|
||||||
|
metadataTracks?: Record<string, Array<[number, number]>>;
|
||||||
|
selectedMetadataVideo?: string | null;
|
||||||
|
gotoPin?: { lat: number; lng: number } | null;
|
||||||
|
// Vertex indices marked for bulk deletion (only the actively-edited road).
|
||||||
|
markedVertices?: Set<number>;
|
||||||
|
onVertexShiftClick?: (idx: number) => void;
|
||||||
layerVisibility?: LayerVisibility;
|
layerVisibility?: LayerVisibility;
|
||||||
selectedAsset?: Asset | null;
|
selectedAsset?: Asset | null;
|
||||||
onSelect?: (asset: Asset | null) => void;
|
onSelect?: (asset: Asset | null) => void;
|
||||||
onAssetRightClick?: (asset: Asset) => void;
|
onAssetRightClick?: (asset: Asset) => void;
|
||||||
onLinkClick?: (idA: number, idB: number) => void;
|
onLinkClick?: (idA: number, idB: number) => void;
|
||||||
|
onMetadataLineClick?: (videoName: string) => void;
|
||||||
popupEnabled?: boolean;
|
popupEnabled?: boolean;
|
||||||
onPositionChange?: (id: number, vertex: Vertex, lat: number, lng: number) => void;
|
onPositionChange?: (id: number, vertex: Vertex, lat: number, lng: number) => void;
|
||||||
placeMode?: boolean;
|
placeMode?: boolean;
|
||||||
@@ -83,6 +90,15 @@ type Props = {
|
|||||||
anchorIds?: number[];
|
anchorIds?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filled rightward-pointing arrow head, white stroke for legibility on any
|
||||||
|
// basemap. Coordinates draw an arrow whose tip is at x=22, base at x=6.
|
||||||
|
const ARROW_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><polygon points="6,3 22,12 6,21 12,12" fill="white" stroke="black" stroke-width="1.5"/></svg>`;
|
||||||
|
const ARROW_DATA_URL = `data:image/svg+xml;utf8,${encodeURIComponent(ARROW_SVG)}`;
|
||||||
|
|
||||||
|
// Google-style location pin: red teardrop with white dot, anchored at the tip.
|
||||||
|
const PIN_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 48"><path d="M16 0C7.16 0 0 7.16 0 16c0 12 16 32 16 32s16-20 16-32C32 7.16 24.84 0 16 0z" fill="#e74c3c" stroke="white" stroke-width="2"/><circle cx="16" cy="16" r="6" fill="white"/></svg>`;
|
||||||
|
const PIN_DATA_URL = `data:image/svg+xml;utf8,${encodeURIComponent(PIN_SVG)}`;
|
||||||
|
|
||||||
const DEFAULT_VIS: LayerVisibility = {
|
const DEFAULT_VIS: LayerVisibility = {
|
||||||
scope: true,
|
scope: true,
|
||||||
metadata: true,
|
metadata: true,
|
||||||
@@ -100,11 +116,17 @@ export function MapView({
|
|||||||
scopePolygons = [],
|
scopePolygons = [],
|
||||||
osmRoads,
|
osmRoads,
|
||||||
metadataTrack = [],
|
metadataTrack = [],
|
||||||
|
metadataTracks = {},
|
||||||
|
selectedMetadataVideo = null,
|
||||||
|
gotoPin = null,
|
||||||
|
markedVertices,
|
||||||
|
onVertexShiftClick,
|
||||||
layerVisibility = DEFAULT_VIS,
|
layerVisibility = DEFAULT_VIS,
|
||||||
selectedAsset,
|
selectedAsset,
|
||||||
onSelect,
|
onSelect,
|
||||||
onAssetRightClick,
|
onAssetRightClick,
|
||||||
onLinkClick,
|
onLinkClick,
|
||||||
|
onMetadataLineClick,
|
||||||
popupEnabled = true,
|
popupEnabled = true,
|
||||||
onPositionChange,
|
onPositionChange,
|
||||||
placeMode = false,
|
placeMode = false,
|
||||||
@@ -137,6 +159,8 @@ export function MapView({
|
|||||||
onAssetRightClickRef.current = onAssetRightClick;
|
onAssetRightClickRef.current = onAssetRightClick;
|
||||||
const onLinkClickRef = useRef(onLinkClick);
|
const onLinkClickRef = useRef(onLinkClick);
|
||||||
onLinkClickRef.current = onLinkClick;
|
onLinkClickRef.current = onLinkClick;
|
||||||
|
const onMetadataLineClickRef = useRef(onMetadataLineClick);
|
||||||
|
onMetadataLineClickRef.current = onMetadataLineClick;
|
||||||
onSelectRef.current = onSelect;
|
onSelectRef.current = onSelect;
|
||||||
const onPositionChangeRef = useRef(onPositionChange);
|
const onPositionChangeRef = useRef(onPositionChange);
|
||||||
onPositionChangeRef.current = onPositionChange;
|
onPositionChangeRef.current = onPositionChange;
|
||||||
@@ -231,6 +255,13 @@ export function MapView({
|
|||||||
// In edit mode, canvas clicks elsewhere — ignore.
|
// In edit mode, canvas clicks elsewhere — ignore.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (info.layer?.id === "metadata-lines" && info.object) {
|
||||||
|
const o = info.object as { name?: string };
|
||||||
|
if (typeof o.name === "string") {
|
||||||
|
onMetadataLineClickRef.current?.(o.name);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (info.layer?.id === "asset-links" && info.object) {
|
if (info.layer?.id === "asset-links" && info.object) {
|
||||||
const o = info.object as { idA?: number; idB?: number };
|
const o = info.object as { idA?: number; idB?: number };
|
||||||
if (typeof o.idA === "number" && typeof o.idB === "number") {
|
if (typeof o.idA === "number" && typeof o.idB === "number") {
|
||||||
@@ -554,7 +585,280 @@ export function MapView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (layerVisibility.metadata && metadataTrack.length > 1) {
|
if (layerVisibility.metadata) {
|
||||||
|
// Render each per-video metadata polyline as its own PathLayer line.
|
||||||
|
// Falls back to the single concatenated metadataTrack for legacy imports.
|
||||||
|
// Viewport cull: tracks whose bbox doesn't intersect the visible map are
|
||||||
|
// skipped — at 45 videos × 150 points the saving is huge.
|
||||||
|
// Zoom-based decimation: keep ~20 points at z≤10, scaling up with zoom.
|
||||||
|
const targetPoints = Math.min(
|
||||||
|
400,
|
||||||
|
Math.max(20, Math.floor(20 * Math.pow(1.6, viewport.zoom - 10))),
|
||||||
|
);
|
||||||
|
const m = mapRef.current;
|
||||||
|
const b = m ? m.getBounds() : null;
|
||||||
|
const vMinLng = b?.getWest() ?? -180;
|
||||||
|
const vMaxLng = b?.getEast() ?? 180;
|
||||||
|
const vMinLat = b?.getSouth() ?? -90;
|
||||||
|
const vMaxLat = b?.getNorth() ?? 90;
|
||||||
|
const cull = (track: Array<[number, number]>): boolean => {
|
||||||
|
let mnLng = Infinity,
|
||||||
|
mxLng = -Infinity,
|
||||||
|
mnLat = Infinity,
|
||||||
|
mxLat = -Infinity;
|
||||||
|
for (const [lng, lat] of track) {
|
||||||
|
if (lng < mnLng) mnLng = lng;
|
||||||
|
if (lng > mxLng) mxLng = lng;
|
||||||
|
if (lat < mnLat) mnLat = lat;
|
||||||
|
if (lat > mxLat) mxLat = lat;
|
||||||
|
}
|
||||||
|
// True if track bbox does NOT intersect viewport.
|
||||||
|
return (
|
||||||
|
mxLng < vMinLng ||
|
||||||
|
mnLng > vMaxLng ||
|
||||||
|
mxLat < vMinLat ||
|
||||||
|
mnLat > vMaxLat
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const decimate = (
|
||||||
|
track: Array<[number, number]>,
|
||||||
|
): Array<[number, number]> => {
|
||||||
|
if (track.length <= targetPoints) return track;
|
||||||
|
const stride = Math.max(1, Math.floor(track.length / targetPoints));
|
||||||
|
const out: Array<[number, number]> = [];
|
||||||
|
for (let i = 0; i < track.length; i += stride) out.push(track[i]);
|
||||||
|
// Always keep the last point so the line closes correctly.
|
||||||
|
if (out[out.length - 1] !== track[track.length - 1]) {
|
||||||
|
out.push(track[track.length - 1]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
const trackEntries: Array<{
|
||||||
|
name: string;
|
||||||
|
path: Array<[number, number]>;
|
||||||
|
}> = [];
|
||||||
|
const multi = Object.keys(metadataTracks);
|
||||||
|
if (multi.length > 0) {
|
||||||
|
for (const k of multi) {
|
||||||
|
const t = metadataTracks[k];
|
||||||
|
if (!t || t.length < 2) continue;
|
||||||
|
if (cull(t)) continue;
|
||||||
|
trackEntries.push({ name: k, path: decimate(t) });
|
||||||
|
}
|
||||||
|
} else if (metadataTrack.length >= 2) {
|
||||||
|
if (!cull(metadataTrack)) {
|
||||||
|
trackEntries.push({ name: "metadata", path: decimate(metadataTrack) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Direction arrows render only for the *tapped* track to keep the map
|
||||||
|
// clean and snappy. Density scales with zoom so the user sees a few
|
||||||
|
// arrows when zoomed out and many when zoomed in.
|
||||||
|
const arrowEntries =
|
||||||
|
selectedMetadataVideo !== null
|
||||||
|
? trackEntries.filter((e) => e.name === selectedMetadataVideo)
|
||||||
|
: [];
|
||||||
|
if (arrowEntries.length > 0) {
|
||||||
|
// Web Mercator m/pixel at the current viewport center, then convert to
|
||||||
|
// a "one arrow per ~80 px" spacing in metres.
|
||||||
|
const lat0 =
|
||||||
|
(typeof viewport.center?.[1] === "number" ? viewport.center[1] : 0) *
|
||||||
|
(Math.PI / 180);
|
||||||
|
const mPerPx =
|
||||||
|
(156543.03392 * Math.cos(lat0)) / Math.pow(2, viewport.zoom);
|
||||||
|
const spacingM = Math.max(20, mPerPx * 80);
|
||||||
|
const arrows: Array<{
|
||||||
|
pos: [number, number];
|
||||||
|
angle: number;
|
||||||
|
name: string;
|
||||||
|
}> = [];
|
||||||
|
const haversine = (
|
||||||
|
a: [number, number],
|
||||||
|
b: [number, number],
|
||||||
|
): number => {
|
||||||
|
const R = 6371000;
|
||||||
|
const φ1 = (a[1] * Math.PI) / 180;
|
||||||
|
const φ2 = (b[1] * Math.PI) / 180;
|
||||||
|
const dφ = ((b[1] - a[1]) * Math.PI) / 180;
|
||||||
|
const dλ = ((b[0] - a[0]) * Math.PI) / 180;
|
||||||
|
const s =
|
||||||
|
Math.sin(dφ / 2) ** 2 +
|
||||||
|
Math.cos(φ1) * Math.cos(φ2) * Math.sin(dλ / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(s));
|
||||||
|
};
|
||||||
|
for (const entry of arrowEntries) {
|
||||||
|
const path = entry.path;
|
||||||
|
if (path.length < 2) continue;
|
||||||
|
// Cumulative distance for the track.
|
||||||
|
let acc = 0;
|
||||||
|
let nextMark = spacingM;
|
||||||
|
// Hard cap per-track. With many videos we also rely on the global
|
||||||
|
// cap below to keep total arrow count manageable.
|
||||||
|
let placed = 0;
|
||||||
|
const maxPerTrack = 40;
|
||||||
|
for (let i = 1; i < path.length && placed < maxPerTrack; i++) {
|
||||||
|
const a = path[i - 1];
|
||||||
|
const b = path[i];
|
||||||
|
const segLen = haversine(a, b);
|
||||||
|
if (segLen === 0) continue;
|
||||||
|
const segStart = acc;
|
||||||
|
const segEnd = acc + segLen;
|
||||||
|
while (nextMark <= segEnd && placed < maxPerTrack) {
|
||||||
|
const t = (nextMark - segStart) / segLen;
|
||||||
|
const lng = a[0] + (b[0] - a[0]) * t;
|
||||||
|
const lat = a[1] + (b[1] - a[1]) * t;
|
||||||
|
const angle =
|
||||||
|
(Math.atan2(b[1] - a[1], b[0] - a[0]) * 180) / Math.PI;
|
||||||
|
arrows.push({
|
||||||
|
pos: [lng, lat],
|
||||||
|
angle,
|
||||||
|
name: entry.name,
|
||||||
|
});
|
||||||
|
nextMark += spacingM;
|
||||||
|
placed++;
|
||||||
|
}
|
||||||
|
acc = segEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (arrows.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new IconLayer<{
|
||||||
|
pos: [number, number];
|
||||||
|
angle: number;
|
||||||
|
name: string;
|
||||||
|
}>({
|
||||||
|
id: "metadata-track-arrows",
|
||||||
|
data: arrows,
|
||||||
|
getIcon: () => ({
|
||||||
|
url: ARROW_DATA_URL,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
mask: false,
|
||||||
|
}),
|
||||||
|
getPosition: (d) => d.pos,
|
||||||
|
getSize: 28,
|
||||||
|
sizeUnits: "pixels",
|
||||||
|
getAngle: (d) => d.angle,
|
||||||
|
pickable: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (trackEntries.length > 0) {
|
||||||
|
layers.push(
|
||||||
|
new PathLayer<{ name: string; path: Array<[number, number]> }>({
|
||||||
|
id: "metadata-lines-halo",
|
||||||
|
data: trackEntries,
|
||||||
|
getPath: (d) => d.path,
|
||||||
|
getColor: [0, 0, 0, 200],
|
||||||
|
getWidth: 6,
|
||||||
|
widthUnits: "pixels",
|
||||||
|
widthMinPixels: 5,
|
||||||
|
pickable: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
layers.push(
|
||||||
|
new PathLayer<{ name: string; path: Array<[number, number]> }>({
|
||||||
|
id: "metadata-lines",
|
||||||
|
data: trackEntries,
|
||||||
|
getPath: (d) => d.path,
|
||||||
|
getColor: (d) => {
|
||||||
|
const [r, g, b] = videoColorRGB(d.name);
|
||||||
|
return [r, g, b, 255];
|
||||||
|
},
|
||||||
|
getWidth: (d) =>
|
||||||
|
selectedMetadataVideo === d.name ? 6 : 3,
|
||||||
|
widthUnits: "pixels",
|
||||||
|
widthMinPixels: 2,
|
||||||
|
pickable: true,
|
||||||
|
updateTriggers: { getWidth: [selectedMetadataVideo] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Selected-track decorations: name badge above midpoint + Start/End
|
||||||
|
// markers at the track's first and last points.
|
||||||
|
if (selectedMetadataVideo !== null) {
|
||||||
|
const sel = trackEntries.find(
|
||||||
|
(e) => e.name === selectedMetadataVideo,
|
||||||
|
);
|
||||||
|
if (sel && sel.path.length > 1) {
|
||||||
|
const mid = sel.path[Math.floor(sel.path.length / 2)];
|
||||||
|
const start = sel.path[0];
|
||||||
|
const end = sel.path[sel.path.length - 1];
|
||||||
|
layers.push(
|
||||||
|
new TextLayer<{ pos: [number, number]; text: string }>({
|
||||||
|
id: "metadata-name-label",
|
||||||
|
data: [{ pos: mid, text: selectedMetadataVideo }],
|
||||||
|
getPosition: (d) => d.pos,
|
||||||
|
getText: (d) => d.text,
|
||||||
|
getColor: [255, 255, 255, 255],
|
||||||
|
backgroundColor: [0, 0, 0, 220],
|
||||||
|
background: true,
|
||||||
|
backgroundPadding: [6, 4, 6, 4],
|
||||||
|
getSize: 14,
|
||||||
|
sizeUnits: "pixels",
|
||||||
|
getTextAnchor: "middle",
|
||||||
|
getAlignmentBaseline: "bottom",
|
||||||
|
getPixelOffset: [0, -16],
|
||||||
|
fontFamily: "Arial, sans-serif",
|
||||||
|
fontWeight: 700,
|
||||||
|
pickable: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
layers.push(
|
||||||
|
new ScatterplotLayer<{
|
||||||
|
pos: [number, number];
|
||||||
|
kind: "S" | "E";
|
||||||
|
}>({
|
||||||
|
id: "metadata-endpoints-rings",
|
||||||
|
data: [
|
||||||
|
{ pos: start, kind: "S" },
|
||||||
|
{ pos: end, kind: "E" },
|
||||||
|
],
|
||||||
|
getPosition: (d) => d.pos,
|
||||||
|
getRadius: 14,
|
||||||
|
radiusUnits: "pixels",
|
||||||
|
getFillColor: (d) =>
|
||||||
|
d.kind === "S" ? [46, 204, 113, 255] : [231, 76, 60, 255],
|
||||||
|
getLineColor: [255, 255, 255, 255],
|
||||||
|
stroked: true,
|
||||||
|
getLineWidth: 2,
|
||||||
|
lineWidthUnits: "pixels",
|
||||||
|
pickable: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
layers.push(
|
||||||
|
new TextLayer<{
|
||||||
|
pos: [number, number];
|
||||||
|
kind: "S" | "E";
|
||||||
|
}>({
|
||||||
|
id: "metadata-endpoints-text",
|
||||||
|
data: [
|
||||||
|
{ pos: start, kind: "S" },
|
||||||
|
{ pos: end, kind: "E" },
|
||||||
|
],
|
||||||
|
getPosition: (d) => d.pos,
|
||||||
|
getText: (d) => d.kind,
|
||||||
|
getColor: [255, 255, 255, 255],
|
||||||
|
getSize: 14,
|
||||||
|
sizeUnits: "pixels",
|
||||||
|
getTextAnchor: "middle",
|
||||||
|
getAlignmentBaseline: "center",
|
||||||
|
fontFamily: "Arial Black, Arial, sans-serif",
|
||||||
|
fontWeight: 900,
|
||||||
|
pickable: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Skip the legacy single-track dot / arrow rendering when per-video
|
||||||
|
// metadata is loaded — the multi-track block above already covers it and
|
||||||
|
// the concatenated metadataTrack is huge (45 videos × 150 points).
|
||||||
|
if (
|
||||||
|
layerVisibility.metadata &&
|
||||||
|
metadataTrack.length > 1 &&
|
||||||
|
Object.keys(metadataTracks).length === 0
|
||||||
|
) {
|
||||||
const N = metadataTrack.length;
|
const N = metadataTrack.length;
|
||||||
const arrowCount = Math.min(8, Math.max(3, Math.floor(N / 50)));
|
const arrowCount = Math.min(8, Math.max(3, Math.floor(N / 50)));
|
||||||
const arrows: Array<{ pos: [number, number]; angle: number }> = [];
|
const arrows: Array<{ pos: [number, number]; angle: number }> = [];
|
||||||
@@ -1009,6 +1313,25 @@ export function MapView({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (gotoPin) {
|
||||||
|
layers.push(
|
||||||
|
new IconLayer<{ pos: [number, number] }>({
|
||||||
|
id: "goto-pin",
|
||||||
|
data: [{ pos: [gotoPin.lng, gotoPin.lat] }],
|
||||||
|
getIcon: () => ({
|
||||||
|
url: PIN_DATA_URL,
|
||||||
|
width: 64,
|
||||||
|
height: 96,
|
||||||
|
mask: false,
|
||||||
|
anchorY: 96, // tip of the pin sits on the coordinate
|
||||||
|
}),
|
||||||
|
getPosition: (d) => d.pos,
|
||||||
|
getSize: 44,
|
||||||
|
sizeUnits: "pixels",
|
||||||
|
pickable: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
overlay.setProps({ layers });
|
overlay.setProps({ layers });
|
||||||
}, [
|
}, [
|
||||||
fixedPoints,
|
fixedPoints,
|
||||||
@@ -1030,6 +1353,12 @@ export function MapView({
|
|||||||
fixedDeleted,
|
fixedDeleted,
|
||||||
rangeDeleted,
|
rangeDeleted,
|
||||||
rangePoints,
|
rangePoints,
|
||||||
|
gotoPin,
|
||||||
|
selectedMetadataVideo,
|
||||||
|
metadataTracks,
|
||||||
|
metadataTrack,
|
||||||
|
layerVisibility,
|
||||||
|
viewport.zoom,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Manage draggable HTML markers + per-vertex popup for the actively-edited asset.
|
// Manage draggable HTML markers + per-vertex popup for the actively-edited asset.
|
||||||
@@ -1460,6 +1789,14 @@ export function MapView({
|
|||||||
|
|
||||||
// Build per-road coords arrays once; share with closures.
|
// Build per-road coords arrays once; share with closures.
|
||||||
for (const f of osmRoads.features) {
|
for (const f of osmRoads.features) {
|
||||||
|
// When the user has picked a single road for editing, only render its
|
||||||
|
// vertices. Everyone else's markers stay off so the map stays snappy.
|
||||||
|
if (
|
||||||
|
editingRoadId !== null &&
|
||||||
|
f.properties.id !== editingRoadId
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const geom = f.geometry as
|
const geom = f.geometry as
|
||||||
| { type: string; coordinates: Array<[number, number]> }
|
| { type: string; coordinates: Array<[number, number]> }
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -1528,9 +1865,21 @@ export function MapView({
|
|||||||
for (const s of visibleSpots) {
|
for (const s of visibleSpots) {
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
const pin = document.createElement("div");
|
const pin = document.createElement("div");
|
||||||
pin.className = "vertex-pin v-osm";
|
const marked = markedVertices?.has(s.idx) ?? false;
|
||||||
pin.textContent = "•";
|
pin.className = marked
|
||||||
|
? "vertex-pin v-osm-marked"
|
||||||
|
: "vertex-pin v-osm";
|
||||||
|
pin.textContent = marked ? "✕" : "•";
|
||||||
el.appendChild(pin);
|
el.appendChild(pin);
|
||||||
|
// Shift-click toggles "marked for bulk delete". Plain click falls
|
||||||
|
// through to the drag handle.
|
||||||
|
el.addEventListener("mousedown", (ev) => {
|
||||||
|
if (ev.shiftKey) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
onVertexShiftClick?.(s.idx);
|
||||||
|
}
|
||||||
|
});
|
||||||
const m = new maplibregl.Marker({ element: el, draggable: true })
|
const m = new maplibregl.Marker({ element: el, draggable: true })
|
||||||
.setLngLat(s.coord)
|
.setLngLat(s.coord)
|
||||||
.addTo(map);
|
.addTo(map);
|
||||||
@@ -1570,7 +1919,7 @@ export function MapView({
|
|||||||
map.off("moveend", renderMarkers);
|
map.off("moveend", renderMarkers);
|
||||||
markers.forEach((m) => m.remove());
|
markers.forEach((m) => m.remove());
|
||||||
};
|
};
|
||||||
}, [osmEditMode, osmRoads, onUpdateRoad]);
|
}, [osmEditMode, osmRoads, onUpdateRoad, editingRoadId, markedVertices, onVertexShiftClick]);
|
||||||
|
|
||||||
return <div ref={containerRef} className="map-pane" />;
|
return <div ref={containerRef} className="map-pane" />;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user