osm merge, optmisation
This commit is contained in:
1333
src/App.tsx
1333
src/App.tsx
File diff suppressed because it is too large
Load Diff
272
src/LensView.tsx
Normal file
272
src/LensView.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import maplibregl, { Map as MapLibreMap } from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import { basemapById } from "./basemaps";
|
||||
import type { Asset } from "./types";
|
||||
import type { OsmRoadFC } from "./MapView";
|
||||
|
||||
type Props = {
|
||||
basemapId: string;
|
||||
centerLng: number;
|
||||
centerLat: number;
|
||||
zoom: number;
|
||||
diameterPx: number;
|
||||
osmRoads?: OsmRoadFC;
|
||||
assets: Asset[];
|
||||
metadataTrack?: Array<[number, number]>;
|
||||
};
|
||||
|
||||
// Cursor-following magnifier: a circular MapLibre instance that re-projects
|
||||
// to whatever the user's cursor is hovering on the main map, at +3 zoom. No
|
||||
// deck.gl — assets and OSM roads render as native MapLibre layers from
|
||||
// GeoJSON sources so each cursor move is a cheap setCenter() call instead of
|
||||
// a full layer rebuild.
|
||||
export function LensView({
|
||||
basemapId,
|
||||
centerLng,
|
||||
centerLat,
|
||||
zoom,
|
||||
diameterPx,
|
||||
osmRoads,
|
||||
assets,
|
||||
metadataTrack = [],
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<MapLibreMap | null>(null);
|
||||
const styleReadyRef = useRef<boolean>(false);
|
||||
|
||||
// Hold refs to current data so installLayers can push the live state right
|
||||
// after creating the empty sources — without this, the lens mounts blank
|
||||
// until one of the data deps below changes (which often never happens).
|
||||
const osmRoadsRef = useRef(osmRoads);
|
||||
osmRoadsRef.current = osmRoads;
|
||||
const assetsRef = useRef(assets);
|
||||
assetsRef.current = assets;
|
||||
const metadataTrackRef = useRef(metadataTrack);
|
||||
metadataTrackRef.current = metadataTrack;
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: basemapById(basemapId).style,
|
||||
center: [centerLng, centerLat],
|
||||
zoom,
|
||||
interactive: false,
|
||||
attributionControl: false,
|
||||
});
|
||||
mapRef.current = map;
|
||||
map.on("load", () => {
|
||||
styleReadyRef.current = true;
|
||||
installLayers(map);
|
||||
pushAllData(map);
|
||||
});
|
||||
return () => {
|
||||
mapRef.current = null;
|
||||
styleReadyRef.current = false;
|
||||
map.remove();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Re-install layers when basemap changes (style swap drops sources/layers).
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
styleReadyRef.current = false;
|
||||
map.setStyle(basemapById(basemapId).style);
|
||||
map.once("styledata", () => {
|
||||
styleReadyRef.current = true;
|
||||
installLayers(map);
|
||||
pushAllData(map);
|
||||
});
|
||||
}, [basemapId]);
|
||||
|
||||
function pushAllData(map: MapLibreMap) {
|
||||
const osmSrc = map.getSource("lens-osm-roads") as
|
||||
| maplibregl.GeoJSONSource
|
||||
| undefined;
|
||||
if (osmSrc && osmRoadsRef.current) {
|
||||
osmSrc.setData(
|
||||
osmRoadsRef.current as unknown as GeoJSON.FeatureCollection<GeoJSON.Geometry>,
|
||||
);
|
||||
}
|
||||
const assetSrc = map.getSource("lens-assets") as
|
||||
| maplibregl.GeoJSONSource
|
||||
| undefined;
|
||||
if (assetSrc) {
|
||||
const features = assetsRef.current
|
||||
.filter(
|
||||
(a) => typeof a.lat === "number" && typeof a.lng === "number",
|
||||
)
|
||||
.map((a) => ({
|
||||
type: "Feature" as const,
|
||||
geometry: {
|
||||
type: "Point" as const,
|
||||
coordinates: [a.lng as number, a.lat as number],
|
||||
},
|
||||
properties: { id: a.id, side: a.side ?? "" },
|
||||
}));
|
||||
assetSrc.setData({ type: "FeatureCollection", features });
|
||||
}
|
||||
const trackSrc = map.getSource("lens-metadata") as
|
||||
| maplibregl.GeoJSONSource
|
||||
| undefined;
|
||||
if (trackSrc) {
|
||||
const t = metadataTrackRef.current;
|
||||
if (t.length < 2) {
|
||||
trackSrc.setData({ type: "FeatureCollection", features: [] });
|
||||
} else {
|
||||
trackSrc.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature" as const,
|
||||
geometry: {
|
||||
type: "LineString" as const,
|
||||
coordinates: t.slice(),
|
||||
},
|
||||
properties: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push live data into the GeoJSON sources whenever they change.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !styleReadyRef.current) return;
|
||||
const src = map.getSource("lens-osm-roads") as
|
||||
| maplibregl.GeoJSONSource
|
||||
| undefined;
|
||||
if (src && osmRoads) {
|
||||
src.setData(
|
||||
osmRoads as unknown as GeoJSON.FeatureCollection<GeoJSON.Geometry>,
|
||||
);
|
||||
}
|
||||
}, [osmRoads]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !styleReadyRef.current) return;
|
||||
const src = map.getSource("lens-assets") as
|
||||
| maplibregl.GeoJSONSource
|
||||
| undefined;
|
||||
if (!src) return;
|
||||
const features = assets
|
||||
.filter((a) => typeof a.lat === "number" && typeof a.lng === "number")
|
||||
.map((a) => ({
|
||||
type: "Feature" as const,
|
||||
geometry: {
|
||||
type: "Point" as const,
|
||||
coordinates: [a.lng as number, a.lat as number],
|
||||
},
|
||||
properties: { id: a.id, side: a.side ?? "" },
|
||||
}));
|
||||
src.setData({ type: "FeatureCollection", features });
|
||||
}, [assets]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !styleReadyRef.current) return;
|
||||
const src = map.getSource("lens-metadata") as
|
||||
| maplibregl.GeoJSONSource
|
||||
| undefined;
|
||||
if (!src) return;
|
||||
if (metadataTrack.length < 2) {
|
||||
src.setData({ type: "FeatureCollection", features: [] });
|
||||
return;
|
||||
}
|
||||
src.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature" as const,
|
||||
geometry: {
|
||||
type: "LineString" as const,
|
||||
coordinates: metadataTrack.slice(),
|
||||
},
|
||||
properties: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
}, [metadataTrack]);
|
||||
|
||||
// Track the cursor world coordinate via prop changes.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
if (!Number.isFinite(centerLng) || !Number.isFinite(centerLat)) return;
|
||||
map.jumpTo({ center: [centerLng, centerLat], zoom });
|
||||
}, [centerLng, centerLat, zoom]);
|
||||
|
||||
function installLayers(map: MapLibreMap) {
|
||||
if (!map.getSource("lens-osm-roads")) {
|
||||
map.addSource("lens-osm-roads", {
|
||||
type: "geojson",
|
||||
data: { type: "FeatureCollection", features: [] },
|
||||
});
|
||||
map.addLayer({
|
||||
id: "lens-osm-roads",
|
||||
type: "line",
|
||||
source: "lens-osm-roads",
|
||||
paint: {
|
||||
"line-color": "#00e5ff",
|
||||
"line-width": 2,
|
||||
"line-opacity": 0.85,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!map.getSource("lens-metadata")) {
|
||||
map.addSource("lens-metadata", {
|
||||
type: "geojson",
|
||||
data: { type: "FeatureCollection", features: [] },
|
||||
});
|
||||
map.addLayer({
|
||||
id: "lens-metadata",
|
||||
type: "line",
|
||||
source: "lens-metadata",
|
||||
paint: {
|
||||
"line-color": "#27ae60",
|
||||
"line-width": 2,
|
||||
"line-opacity": 0.9,
|
||||
"line-dasharray": [2, 2],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!map.getSource("lens-assets")) {
|
||||
map.addSource("lens-assets", {
|
||||
type: "geojson",
|
||||
data: { type: "FeatureCollection", features: [] },
|
||||
});
|
||||
map.addLayer({
|
||||
id: "lens-assets",
|
||||
type: "circle",
|
||||
source: "lens-assets",
|
||||
paint: {
|
||||
"circle-radius": 5,
|
||||
"circle-color": "#e74c3c",
|
||||
"circle-stroke-color": "#ffffff",
|
||||
"circle-stroke-width": 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: diameterPx,
|
||||
height: diameterPx,
|
||||
borderRadius: "50%",
|
||||
overflow: "hidden",
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.45)",
|
||||
border: "2px solid #fff",
|
||||
background: "#000",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
102
src/MapView.tsx
102
src/MapView.tsx
@@ -27,7 +27,7 @@ export type OsmRoadFC = {
|
||||
features: Array<{
|
||||
type: "Feature";
|
||||
geometry: unknown;
|
||||
properties: { id: number; name: string | null };
|
||||
properties: { id: number; name: string | null; snap_ignored?: boolean };
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -76,6 +76,9 @@ type Props = {
|
||||
osmEditMode?: boolean;
|
||||
editingRoadId?: number | null;
|
||||
onPickRoad?: (id: number) => void;
|
||||
onRoadRightClick?: (id: number) => void;
|
||||
selectedRoadIds?: Set<number>;
|
||||
multiSelectActive?: boolean;
|
||||
onUpdateRoad?: (id: number, coords: Array<[number, number]>) => void;
|
||||
roadOffsetMeters?: number;
|
||||
drawRoadMode?: boolean;
|
||||
@@ -89,6 +92,12 @@ type Props = {
|
||||
) => void;
|
||||
highlightedIds?: number[];
|
||||
anchorIds?: number[];
|
||||
onCursorWorld?: (
|
||||
lng: number,
|
||||
lat: number,
|
||||
screenX: number,
|
||||
screenY: number,
|
||||
) => void;
|
||||
};
|
||||
|
||||
// Filled rightward-pointing arrow head, white stroke for legibility on any
|
||||
@@ -138,6 +147,9 @@ export function MapView({
|
||||
osmEditMode = false,
|
||||
editingRoadId = null,
|
||||
onPickRoad,
|
||||
onRoadRightClick,
|
||||
selectedRoadIds,
|
||||
multiSelectActive = false,
|
||||
onUpdateRoad,
|
||||
roadOffsetMeters = 4,
|
||||
drawRoadMode = false,
|
||||
@@ -148,6 +160,7 @@ export function MapView({
|
||||
onLassoComplete,
|
||||
highlightedIds = [],
|
||||
anchorIds = [],
|
||||
onCursorWorld,
|
||||
}: Props) {
|
||||
// Diagnostic counters — visible in DevTools console. Should stay flat
|
||||
// during pure pan/zoom; spikes on filter or import.
|
||||
@@ -179,6 +192,10 @@ export function MapView({
|
||||
osmEditModeRef.current = osmEditMode;
|
||||
const onPickRoadRef = useRef(onPickRoad);
|
||||
onPickRoadRef.current = onPickRoad;
|
||||
const onRoadRightClickRef = useRef(onRoadRightClick);
|
||||
onRoadRightClickRef.current = onRoadRightClick;
|
||||
const onCursorWorldRef = useRef(onCursorWorld);
|
||||
onCursorWorldRef.current = onCursorWorld;
|
||||
const drawRoadModeRef = useRef(drawRoadMode);
|
||||
drawRoadModeRef.current = drawRoadMode;
|
||||
const onDrawRoadAddPointRef = useRef(onDrawRoadAddPoint);
|
||||
@@ -208,20 +225,46 @@ export function MapView({
|
||||
|
||||
map.on("contextmenu", (e) => {
|
||||
const overlay = overlayRef.current;
|
||||
if (!overlay || !onAssetRightClickRef.current) return;
|
||||
if (!overlay) return;
|
||||
const { x, y } = e.point;
|
||||
const info = overlay.pickObject({ x, y, radius: 8 }) as
|
||||
| { object?: unknown; layer?: { id?: string } }
|
||||
| {
|
||||
object?: unknown;
|
||||
layer?: { id?: string };
|
||||
sourceLayer?: { id?: string };
|
||||
}
|
||||
| null;
|
||||
if (!info || !info.object) return;
|
||||
// An asset record carries `row_id` and `asset_type`; ignore picks that
|
||||
// landed on roads/scopes/metadata.
|
||||
// Road right-click takes priority when in OSM edit mode.
|
||||
if (
|
||||
osmEditModeRef.current &&
|
||||
info.layer?.id === "osm-roads" &&
|
||||
onRoadRightClickRef.current
|
||||
) {
|
||||
const f = info.object as { properties?: { id?: number } };
|
||||
const rid = f.properties?.id;
|
||||
if (typeof rid === "number") {
|
||||
e.preventDefault();
|
||||
onRoadRightClickRef.current(rid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!onAssetRightClickRef.current) return;
|
||||
const obj = info.object as Partial<Asset>;
|
||||
if (typeof obj.row_id !== "string" || typeof obj.id !== "number") return;
|
||||
e.preventDefault();
|
||||
onAssetRightClickRef.current(obj as Asset);
|
||||
});
|
||||
|
||||
map.on("mousemove", (e) => {
|
||||
const cb = onCursorWorldRef.current;
|
||||
if (!cb) return;
|
||||
cb(e.lngLat.lng, e.lngLat.lat, e.point.x, e.point.y);
|
||||
});
|
||||
map.on("mouseleave", () => {
|
||||
onCursorWorldRef.current?.(NaN, NaN, NaN, NaN);
|
||||
});
|
||||
|
||||
map.on("moveend", () => {
|
||||
setIsInteracting(false);
|
||||
// Sync the React viewport mirror once per gesture so programmatic
|
||||
@@ -448,8 +491,14 @@ export function MapView({
|
||||
|
||||
const layers: unknown[] = [];
|
||||
|
||||
// Vertex-edit mode collapses to the single road being dragged so other
|
||||
// roads don't clutter the editor. But when multi-select is active (focused
|
||||
// video + bulk select / hide / merge workflow), keep all roads visible.
|
||||
const visibleOsm: OsmRoadFC | undefined =
|
||||
osmEditMode && editingRoadId !== null && osmRoads
|
||||
osmEditMode &&
|
||||
editingRoadId !== null &&
|
||||
!multiSelectActive &&
|
||||
osmRoads
|
||||
? {
|
||||
type: "FeatureCollection",
|
||||
features: osmRoads.features.filter(
|
||||
@@ -480,10 +529,28 @@ export function MapView({
|
||||
data: visibleOsm,
|
||||
stroked: true,
|
||||
filled: false,
|
||||
getLineColor: [0, 229, 255, 255],
|
||||
getLineWidth: 3,
|
||||
getLineColor: (f: unknown) => {
|
||||
const p = (f as { properties?: { id?: number; snap_ignored?: boolean } })
|
||||
.properties;
|
||||
if (p && selectedRoadIds && typeof p.id === "number" && selectedRoadIds.has(p.id)) {
|
||||
return [255, 196, 0, 255];
|
||||
}
|
||||
if (p?.snap_ignored) return [120, 120, 120, 180];
|
||||
return [0, 229, 255, 255];
|
||||
},
|
||||
getLineWidth: (f: unknown) => {
|
||||
const p = (f as { properties?: { id?: number } }).properties;
|
||||
if (p && selectedRoadIds && typeof p.id === "number" && selectedRoadIds.has(p.id)) {
|
||||
return 5;
|
||||
}
|
||||
return 3;
|
||||
},
|
||||
lineWidthUnits: "pixels",
|
||||
pickable: osmEditMode,
|
||||
updateTriggers: {
|
||||
getLineColor: [selectedRoadIds],
|
||||
getLineWidth: [selectedRoadIds],
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (osmEditMode && roadOffsetMeters > 0) {
|
||||
@@ -1406,6 +1473,8 @@ export function MapView({
|
||||
zoomBand,
|
||||
isInteracting,
|
||||
perfMode,
|
||||
selectedRoadIds,
|
||||
multiSelectActive,
|
||||
]);
|
||||
|
||||
// Manage draggable HTML markers + per-vertex popup for the actively-edited asset.
|
||||
@@ -1476,7 +1545,22 @@ export function MapView({
|
||||
}
|
||||
cap.appendChild(document.createElement("br"));
|
||||
const small = document.createElement("small");
|
||||
small.textContent = selectedAsset.row_id;
|
||||
// row_id · video_name on a single line so users can read both
|
||||
// at a glance without the popup growing taller.
|
||||
const rowSpan = document.createElement("span");
|
||||
rowSpan.textContent = selectedAsset.row_id;
|
||||
small.appendChild(rowSpan);
|
||||
if (selectedAsset.video_name && selectedAsset.video_name !== "Not available") {
|
||||
const sep = document.createElement("span");
|
||||
sep.style.color = "#888";
|
||||
sep.style.margin = "0 6px";
|
||||
sep.textContent = "·";
|
||||
small.appendChild(sep);
|
||||
const vidSpan = document.createElement("span");
|
||||
vidSpan.style.color = "#666";
|
||||
vidSpan.textContent = selectedAsset.video_name;
|
||||
small.appendChild(vidSpan);
|
||||
}
|
||||
cap.appendChild(small);
|
||||
root.appendChild(cap);
|
||||
|
||||
|
||||
@@ -132,9 +132,9 @@ const STUBS: Record<string, Stub> = {
|
||||
data_source_counts: () => ({
|
||||
fixed_assets: FAKE_ASSETS.length,
|
||||
range_assets: 0,
|
||||
scope_polygons: 0,
|
||||
osm_roads: 0,
|
||||
metadata_polylines: 0,
|
||||
scope_features: 0,
|
||||
video_metadata: 0,
|
||||
}),
|
||||
list_video_names: () =>
|
||||
Array.from(new Set(FAKE_ASSETS.map((a) => a.video_name))),
|
||||
@@ -145,7 +145,15 @@ const STUBS: Record<string, Stub> = {
|
||||
get_scope_polygons: () => [],
|
||||
read_video_polylines_json: () => ({}),
|
||||
read_metadata_kml: () => [],
|
||||
list_osm_roads: () => ({ type: "FeatureCollection", features: [] }),
|
||||
// Backend rename — App invokes get_osm_roads; the legacy list_osm_roads
|
||||
// alias is kept so older browser fixtures don't crash.
|
||||
get_osm_roads: () => [],
|
||||
list_osm_roads: () => [],
|
||||
get_video_metadata: () => ({}),
|
||||
save_video_metadata: () => 0,
|
||||
clear_video_metadata: () => 0,
|
||||
osm_road_snapshot: () => [],
|
||||
restore_road_state: () => 0,
|
||||
};
|
||||
|
||||
export function invoke<T = unknown>(
|
||||
|
||||
Reference in New Issue
Block a user