osm edits

This commit is contained in:
2026-04-27 16:03:21 +05:30
parent 71c6798e53
commit 1f70e01d89
8 changed files with 1594 additions and 16 deletions

View File

@@ -408,6 +408,17 @@ body,
.vertex-pin.v-end { background: #e74c3c; }
.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-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 {
background: rgba(255, 215, 0, 0.45);
color: #222;

View File

@@ -118,6 +118,12 @@ function AppShell({
const [compare, setCompare] = useState<boolean>(false);
const [assets, setAssets] = useState<Asset[]>([]);
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 [status, setStatus] = useState<string>("");
const [busy, setBusy] = useState<boolean>(false);
@@ -143,6 +149,16 @@ function AppShell({
features: [],
});
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>({
scope: true,
metadata: true,
@@ -176,9 +192,21 @@ function AppShell({
const [namesExpanded, setNamesExpanded] = useState<boolean>(true);
const [filterPanelOpen, setFilterPanelOpen] = useState<boolean>(true);
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 [osmEditMode, setOsmEditMode] = useState<boolean>(false);
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 [drawRoadCoords, setDrawRoadCoords] = useState<
Array<[number, number]>
@@ -396,8 +424,12 @@ function AppShell({
names.add(a.asset_name);
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) return "asset";
// Multiple videos visible → match each asset's color to its metadata
// polyline (when loaded) by coloring by video.
return "video";
}, [filteredAssets]);
@@ -499,6 +531,29 @@ function AppShell({
// ESC: deselect editing road (or cancel a draw-in-progress).
useEffect(() => {
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) {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
@@ -523,6 +578,18 @@ function AppShell({
setStatus("Lasso cancelled.");
return;
}
if (selectedMetadataVideo !== null) {
e.preventDefault();
setSelectedMetadataVideo(null);
setStatus("");
return;
}
if (gotoPin !== null) {
e.preventDefault();
setGotoPin(null);
setStatus("");
return;
}
if (linkPickMode) {
e.preventDefault();
setLinkPickMode(false);
@@ -541,7 +608,7 @@ function AppShell({
};
window.addEventListener("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
// immediate neighbours so [/] navigation feels instant.
@@ -887,11 +954,116 @@ function AppShell({
refreshAssetNames(),
refreshVideoNames(),
refreshClasses(),
refreshDataCounts(),
];
if (boundsRef.current) tasks.push(fetchBboxAssets(boundsRef.current));
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(() => {
refreshScopePolygons().catch((e) =>
setStatus(`Scope load failed: ${e}`),
@@ -901,6 +1073,7 @@ function AppShell({
refreshAssetNames().catch(() => {});
refreshVideoNames().catch(() => {});
refreshClasses().catch(() => {});
refreshDataCounts().catch(() => {});
invoke<Asset[]>("list_assets")
.then((list) => {
if (list.length > 0) {
@@ -1205,7 +1378,12 @@ function AppShell({
maxDistanceM: 50,
offsetM: roadOffsetMeters,
centerlineNames: Array.from(centerlineNames),
metadataTrack: metadataTrack.length >= 2 ? metadataTrack : null,
metadataTrack:
metadataByVideo[name]?.length >= 2
? metadataByVideo[name]
: metadataTrack.length >= 2
? metadataTrack
: null,
});
setStatus(
`Snap by video: ${res.snapped} moved, ${res.skipped_far} too far, ${res.skipped_no_geom} skipped`,
@@ -1715,7 +1893,15 @@ function AppShell({
maxDistanceM: 50,
offsetM: roadOffsetMeters,
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(
`Bulk snap: ${res.snapped} moved, ${res.skipped_far} too far, ${res.skipped_no_geom} skipped`,
@@ -1733,11 +1919,18 @@ function AppShell({
setBusy(true);
try {
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", {
assetId: selectedAsset.id,
maxDistanceM: 50,
offsetM: useCenter ? 0 : roadOffsetMeters,
metadataTrack: metadataTrack.length >= 2 ? metadataTrack : null,
metadataTrack: trackForAsset,
});
setStatus(
`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") {
if (busy) return;
setBusy(true);
@@ -1838,6 +2083,25 @@ function AppShell({
scopePolygons={scopePolygons}
osmRoads={osmRoads}
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}
selectedAsset={selectedAsset}
onSelect={(a) => {
@@ -1928,6 +2192,25 @@ function AppShell({
scopePolygons={scopePolygons}
osmRoads={osmRoads}
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}
selectedAsset={selectedAsset}
onSelect={(a) => {
@@ -2148,7 +2431,12 @@ function AppShell({
}}
/>
<div style={{ maxHeight: 240, overflowY: "auto" }}>
{allVideoNames
{Array.from(
new Set(
allVideoNames.concat(Object.keys(metadataByVideo)),
),
)
.sort()
.filter((v) =>
videoSearch.trim() === ""
? true
@@ -2176,6 +2464,25 @@ function AppShell({
{v}
</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>
))}
</div>
@@ -2378,9 +2685,22 @@ function AppShell({
handleImportScope();
}}
disabled={busy}
title="Import a KML scope polygon or polyline (geometric only)."
>
KML scope
</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
className="primary-btn"
style={{ background: "#34495e" }}
@@ -2691,6 +3011,45 @@ function AppShell({
<span>Compare basemaps (side-by-side)</span>
</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>
<button
className="primary-btn"
@@ -2699,6 +3058,166 @@ function AppShell({
>
Import
</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
className="primary-btn"
style={{
@@ -2733,6 +3252,101 @@ function AppShell({
>
Flip direction
</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
className="primary-btn"
style={{ marginTop: 6, background: "#7f8c8d" }}

View File

@@ -7,7 +7,7 @@ import "maplibre-gl/dist/maplibre-gl.css";
// === ['MapboxOverlay']`). Remove this directive if a future TS/deck.gl release surfaces
// the named export.
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 { basemapById } from "./basemaps";
import { videoColorRGB } from "./colors";
@@ -54,11 +54,18 @@ type Props = {
scopePolygons?: ScopePolygon[];
osmRoads?: OsmRoadFC;
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;
selectedAsset?: Asset | null;
onSelect?: (asset: Asset | null) => void;
onAssetRightClick?: (asset: Asset) => void;
onLinkClick?: (idA: number, idB: number) => void;
onMetadataLineClick?: (videoName: string) => void;
popupEnabled?: boolean;
onPositionChange?: (id: number, vertex: Vertex, lat: number, lng: number) => void;
placeMode?: boolean;
@@ -83,6 +90,15 @@ type Props = {
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 = {
scope: true,
metadata: true,
@@ -100,11 +116,17 @@ export function MapView({
scopePolygons = [],
osmRoads,
metadataTrack = [],
metadataTracks = {},
selectedMetadataVideo = null,
gotoPin = null,
markedVertices,
onVertexShiftClick,
layerVisibility = DEFAULT_VIS,
selectedAsset,
onSelect,
onAssetRightClick,
onLinkClick,
onMetadataLineClick,
popupEnabled = true,
onPositionChange,
placeMode = false,
@@ -137,6 +159,8 @@ export function MapView({
onAssetRightClickRef.current = onAssetRightClick;
const onLinkClickRef = useRef(onLinkClick);
onLinkClickRef.current = onLinkClick;
const onMetadataLineClickRef = useRef(onMetadataLineClick);
onMetadataLineClickRef.current = onMetadataLineClick;
onSelectRef.current = onSelect;
const onPositionChangeRef = useRef(onPositionChange);
onPositionChangeRef.current = onPositionChange;
@@ -231,6 +255,13 @@ export function MapView({
// In edit mode, canvas clicks elsewhere — ignore.
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) {
const o = info.object as { idA?: number; 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 = ((b[1] - a[1]) * Math.PI) / 180;
const = ((b[0] - a[0]) * Math.PI) / 180;
const s =
Math.sin( / 2) ** 2 +
Math.cos(φ1) * Math.cos(φ2) * Math.sin( / 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 arrowCount = Math.min(8, Math.max(3, Math.floor(N / 50)));
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 });
}, [
fixedPoints,
@@ -1030,6 +1353,12 @@ export function MapView({
fixedDeleted,
rangeDeleted,
rangePoints,
gotoPin,
selectedMetadataVideo,
metadataTracks,
metadataTrack,
layerVisibility,
viewport.zoom,
]);
// 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.
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
| { type: string; coordinates: Array<[number, number]> }
| undefined;
@@ -1528,9 +1865,21 @@ export function MapView({
for (const s of visibleSpots) {
const el = document.createElement("div");
const pin = document.createElement("div");
pin.className = "vertex-pin v-osm";
pin.textContent = "•";
const marked = markedVertices?.has(s.idx) ?? false;
pin.className = marked
? "vertex-pin v-osm-marked"
: "vertex-pin v-osm";
pin.textContent = marked ? "✕" : "•";
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 })
.setLngLat(s.coord)
.addTo(map);
@@ -1570,7 +1919,7 @@ export function MapView({
map.off("moveend", renderMarkers);
markers.forEach((m) => m.remove());
};
}, [osmEditMode, osmRoads, onUpdateRoad]);
}, [osmEditMode, osmRoads, onUpdateRoad, editingRoadId, markedVertices, onVertexShiftClick]);
return <div ref={containerRef} className="map-pane" />;
}