From ace6c4a4f3bed9281e43bec7b7b4f06be86bc405 Mon Sep 17 00:00:00 2001 From: ravi Date: Tue, 28 Apr 2026 16:17:46 +0530 Subject: [PATCH] performance improvement --- src/App.tsx | 146 +++++++++++++++++++++++++++++++++++--- src/LoginScreen.tsx | 2 +- src/MapView.tsx | 149 +++++++++++++++++++++++++-------------- src/tauriShim.ts | 166 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 400 insertions(+), 63 deletions(-) create mode 100644 src/tauriShim.ts diff --git a/src/App.tsx b/src/App.tsx index fe3d1d5..e15b324 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; +import { invoke, IS_BROWSER_MODE } from "./tauriShim"; import { open, save } from "@tauri-apps/plugin-dialog"; const IMAGE_FOLDER_KEY = "kml_map_tool.image_folder"; const POPUP_SIZE_KEY = "kml_map_tool.popup_size"; const POPUP_ENABLED_KEY = "kml_map_tool.popup_enabled"; const POPUP_ASPECT_KEY = "kml_map_tool.popup_aspect"; +const PERF_MODE_KEY = "kml_map_tool.perf_mode"; type PopupSize = "S" | "M" | "L" | "XL"; const POPUP_WIDTH_PX: Record = { @@ -104,6 +105,50 @@ export default function App() { }} />; } +// Diagnostic FPS overlay. Mounted only when Settings → Performance mode is on. +// Updates twice a second so the readout is stable enough to read while panning. +function FpsCounter() { + const [fps, setFps] = useState(0); + useEffect(() => { + let raf = 0; + let frames = 0; + let last = performance.now(); + const tick = () => { + frames++; + const now = performance.now(); + if (now - last >= 500) { + setFps(Math.round((frames * 1000) / (now - last))); + frames = 0; + last = now; + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, []); + return ( +
= 50 ? "#7CFC00" : fps >= 30 ? "#FFD700" : "#FF6B6B", + padding: "4px 10px", + borderRadius: 4, + fontFamily: "monospace", + fontSize: 12, + fontWeight: 700, + pointerEvents: "none", + }} + > + {fps} fps · perf +
+ ); +} + function AppShell({ currentUser, onLogout, @@ -111,7 +156,11 @@ function AppShell({ currentUser: CurrentUser; onLogout: () => void; }) { - const [viewport, setViewport] = useState(INITIAL_VIEWPORT); + const [viewport, setViewport] = useState( + IS_BROWSER_MODE + ? { center: [78.347, 17.398], zoom: 14 } + : INITIAL_VIEWPORT, + ); const [bounds, setBounds] = useState(null); const [primaryId, setPrimaryId] = useState(DEFAULT_BASEMAP_ID); const [secondaryId, setSecondaryId] = useState("esri-imagery"); @@ -182,6 +231,13 @@ function AppShell({ const v = window.localStorage.getItem(POPUP_ASPECT_KEY) as PopupAspect | null; return v && v in POPUP_ASPECT_VAL ? v : "4:3"; }); + // Diagnostic: while pan/zoom is active, render only basemap + asset + // ScatterplotLayer + range PathLayer. Lets us isolate whether residual + // lag is from layer complexity vs the WebView itself. + const [perfMode, setPerfMode] = useState(() => { + if (typeof window === "undefined") return false; + return window.localStorage.getItem(PERF_MODE_KEY) === "1"; + }); const [filters, setFilters] = useState({ videos: new Set(), sides: new Set(), @@ -323,6 +379,9 @@ function AppShell({ useEffect(() => { window.localStorage.setItem(POPUP_ENABLED_KEY, popupEnabled ? "1" : "0"); }, [popupEnabled]); + useEffect(() => { + window.localStorage.setItem(PERF_MODE_KEY, perfMode ? "1" : "0"); + }, [perfMode]); // Counts on the current bbox slice (live update as user pans/zooms). const counts = useMemo(() => { @@ -887,15 +946,43 @@ function AppShell({ const list = await invoke< Array<{ id: number; name: string | null; geojson: string }> >("get_osm_roads"); - setOsmRoads({ - type: "FeatureCollection", - features: list.map((r) => ({ - type: "Feature", - geometry: JSON.parse(r.geojson), + let mnLat = Infinity; + let mxLat = -Infinity; + let mnLng = Infinity; + let mxLng = -Infinity; + const features = list.map((r) => { + const geom = JSON.parse(r.geojson) as { + type: string; + coordinates: Array<[number, number]>; + }; + if (geom.type === "LineString" && Array.isArray(geom.coordinates)) { + for (const c of geom.coordinates) { + const lng = c[0]; + const lat = c[1]; + if ( + typeof lat === "number" && + typeof lng === "number" && + isFinite(lat) && + isFinite(lng) + ) { + if (lat < mnLat) mnLat = lat; + if (lat > mxLat) mxLat = lat; + if (lng < mnLng) mnLng = lng; + if (lng > mxLng) mxLng = lng; + } + } + } + return { + type: "Feature" as const, + geometry: geom, properties: { id: r.id, name: r.name }, - })), + }; }); - return list; + setOsmRoads({ type: "FeatureCollection", features }); + const bounds = Number.isFinite(mnLat) + ? { minLat: mnLat, maxLat: mxLat, minLng: mnLng, maxLng: mxLng } + : null; + return { list, bounds }; } async function refreshAssetNames() { @@ -1219,8 +1306,30 @@ function AppShell({ } setStatus("Importing OSM roads…"); const count = await invoke("import_osm_geojson", args); - await refreshOsmRoads(); - setStatus(`Loaded ${count} road segment(s)`); + const { bounds } = await refreshOsmRoads(); + // Auto-recentre the map on the imported road bbox. Without this, if + // the user's current viewport is far from the imported area, the + // roads render off-screen and the import looks silently broken. + if (bounds) { + const center: [number, number] = [ + (bounds.minLng + bounds.maxLng) / 2, + (bounds.minLat + bounds.maxLat) / 2, + ]; + const dLat = bounds.maxLat - bounds.minLat; + const dLng = bounds.maxLng - bounds.minLng; + const span = Math.max(dLat, dLng); + // Pick a zoom level that roughly fits the bbox in the viewport. + // Web Mercator: at z=0 the world spans 360°, so zoom for a span s + // is ~log2(360 / s) − a small margin for padding. + const zoom = + span > 0 ? Math.max(8, Math.min(17, Math.log2(360 / span) - 0.5)) : 14; + setViewport({ center, zoom }); + setStatus( + `Loaded ${count} road segment(s). Centred on imported area.`, + ); + } else { + setStatus(`Loaded ${count} road segment(s)`); + } } catch (e) { setStatus(`OSM import failed: ${e}`); } finally { @@ -2073,6 +2182,7 @@ function AppShell({ return (
+ {perfMode && }
displayVideo(a.video_name))) @@ -2864,6 +2976,18 @@ function AppShell({ /> Show image popup when an asset is selected + +

Diagnostics

+

Popup width

{(["S", "M", "L", "XL"] as PopupSize[]).map((s) => { diff --git a/src/LoginScreen.tsx b/src/LoginScreen.tsx index fb8a240..a3e004c 100644 --- a/src/LoginScreen.tsx +++ b/src/LoginScreen.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from "./tauriShim"; type User = { id: number; diff --git a/src/MapView.tsx b/src/MapView.tsx index 832c34e..d3d97df 100644 --- a/src/MapView.tsx +++ b/src/MapView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import maplibregl, { Map as MapLibreMap, LngLatLike } from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; // @ts-expect-error: TS does not see the re-export-of-default in @deck.gl/mapbox's @@ -67,6 +67,7 @@ type Props = { onLinkClick?: (idA: number, idB: number) => void; onMetadataLineClick?: (videoName: string) => void; popupEnabled?: boolean; + perfMode?: boolean; onPositionChange?: (id: number, vertex: Vertex, lat: number, lng: number) => void; placeMode?: boolean; onPlaceAt?: (lat: number, lng: number) => void; @@ -128,6 +129,7 @@ export function MapView({ onLinkClick, onMetadataLineClick, popupEnabled = true, + perfMode = false, onPositionChange, placeMode = false, onPlaceAt, @@ -147,6 +149,11 @@ export function MapView({ highlightedIds = [], anchorIds = [], }: Props) { + // Diagnostic counters — visible in DevTools console. Should stay flat + // during pure pan/zoom; spikes on filter or import. + if (typeof console !== "undefined" && import.meta.env.DEV) { + console.count("MapView render"); + } const containerRef = useRef(null); const mapRef = useRef(null); const overlayRef = useRef(null); @@ -189,11 +196,15 @@ export function MapView({ }); mapRef.current = map; - map.on("move", (e) => { - if (!e.originalEvent) return; - const c = map.getCenter(); - onUserMoveRef.current({ center: [c.lng, c.lat], zoom: map.getZoom() }); - }); + map.on("movestart", () => setIsInteracting(true)); + + // Intentionally NOT mirroring every "move" tick to App state. The map + // owns its own position during a gesture; pushing 60 Hz of setViewport + // re-renders the entire App tree, which on weak GPUs and dense React + // trees costs more than the deck.gl draw itself. We sync once on + // moveend below — App reads viewport only for programmatic flyTo, the + // [/] navigation centre fallback, and the centering effect (which + // early-returns when the map is already at the prop). map.on("contextmenu", (e) => { const overlay = overlayRef.current; @@ -212,6 +223,11 @@ export function MapView({ }); map.on("moveend", () => { + setIsInteracting(false); + // Sync the React viewport mirror once per gesture so programmatic + // consumers (asset-list nav fallback, search flyTo) see fresh values. + const c = map.getCenter(); + onUserMoveRef.current({ center: [c.lng, c.lat], zoom: map.getZoom() }); const b = map.getBounds(); onMoveEndRef.current?.({ bounds: { @@ -382,9 +398,23 @@ export function MapView({ }; }, [scopePolygons]); + // Quantise raw zoom to 0.5 steps so the layer-build effect only re-runs + // when zoom crosses a band boundary, not on every wheel tick / inertial + // frame. Heavy formulas inside the effect (decimation target, arrow + // spacing) read this band rather than viewport.zoom. + const zoomBand = Math.round(viewport.zoom * 2) / 2; + + // True between movestart and moveend. While true, the layer-build effect + // omits decorative overlays (arrows, name badges, S/E markers, link + // halos) so pan / zoom feels responsive on weak GPUs. + const [isInteracting, setIsInteracting] = useState(false); + useEffect(() => { const overlay = overlayRef.current; if (!overlay) return; + if (import.meta.env.DEV) { + console.count("deck layer rebuild"); + } const colorKey = (a: Asset) => colorBy === "asset" ? `${a.video_name}#${a.id}` : a.video_name; @@ -593,7 +623,7 @@ export function MapView({ // 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))), + Math.max(20, Math.floor(20 * Math.pow(1.6, zoomBand - 10))), ); const m = mapRef.current; const b = m ? m.getBounds() : null; @@ -654,7 +684,7 @@ export function MapView({ // 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 + selectedMetadataVideo !== null && !isInteracting ? trackEntries.filter((e) => e.name === selectedMetadataVideo) : []; if (arrowEntries.length > 0) { @@ -664,7 +694,7 @@ export function MapView({ (typeof viewport.center?.[1] === "number" ? viewport.center[1] : 0) * (Math.PI / 180); const mPerPx = - (156543.03392 * Math.cos(lat0)) / Math.pow(2, viewport.zoom); + (156543.03392 * Math.cos(lat0)) / Math.pow(2, zoomBand); const spacingM = Math.max(20, mPerPx * 80); const arrows: Array<{ pos: [number, number]; @@ -774,8 +804,9 @@ export function MapView({ }), ); // Selected-track decorations: name badge above midpoint + Start/End - // markers at the track's first and last points. - if (selectedMetadataVideo !== null) { + // markers at the track's first and last points. Skipped during pan / + // zoom — TextLayer is the most expensive overlay on weak GPUs. + if (selectedMetadataVideo !== null && !isInteracting) { const sel = trackEntries.find( (e) => e.name === selectedMetadataVideo, ); @@ -900,7 +931,7 @@ export function MapView({ pickable: false, }), ); - if (arrows.length > 0) { + if (arrows.length > 0 && !isInteracting) { layers.push( new TextLayer<{ pos: [number, number]; angle: number }>({ id: "metadata-arrows-bg", @@ -955,24 +986,26 @@ export function MapView({ pickable: false, }), ); - layers.push( - new TextLayer<{ pos: [number, number]; kind: "S" | "E" }>({ - id: "metadata-endpoint-labels", - data: [ - { pos: start, kind: "S" as const }, - { pos: end, kind: "E" as const }, - ], - getPosition: (d) => d.pos, - getText: (d) => d.kind, - getColor: [255, 255, 255, 255], - getSize: 11, - sizeUnits: "pixels", - getTextAnchor: "middle", - getAlignmentBaseline: "center", - fontWeight: "bold", - pickable: false, - }), - ); + if (!isInteracting) { + layers.push( + new TextLayer<{ pos: [number, number]; kind: "S" | "E" }>({ + id: "metadata-endpoint-labels", + data: [ + { pos: start, kind: "S" as const }, + { pos: end, kind: "E" as const }, + ], + getPosition: (d) => d.pos, + getText: (d) => d.kind, + getColor: [255, 255, 255, 255], + getSize: 11, + sizeUnits: "pixels", + getTextAnchor: "middle", + getAlignmentBaseline: "center", + fontWeight: "bold", + pickable: false, + }), + ); + } } if (layerVisibility.scope) { layers.push( @@ -1203,23 +1236,25 @@ export function MapView({ }); } if (linkPaths.length > 0) { - layers.push( - new PathLayer<{ - path: Array<[number, number]>; - locked: boolean; - idA: number; - idB: number; - }>({ - id: "asset-links-halo", - data: linkPaths, - getPath: (d) => d.path, - getColor: [255, 255, 255, 200], - getWidth: 9, - widthUnits: "pixels", - widthMinPixels: 7, - pickable: false, - }), - ); + if (!isInteracting) { + layers.push( + new PathLayer<{ + path: Array<[number, number]>; + locked: boolean; + idA: number; + idB: number; + }>({ + id: "asset-links-halo", + data: linkPaths, + getPath: (d) => d.path, + getColor: [255, 255, 255, 200], + getWidth: 9, + widthUnits: "pixels", + widthMinPixels: 7, + pickable: false, + }), + ); + } layers.push( new PathLayer<{ path: Array<[number, number]>; @@ -1332,7 +1367,18 @@ export function MapView({ }), ); } - overlay.setProps({ layers }); + // Performance-mode whitelist: while a gesture is active and perfMode is + // on, drop everything except the asset ScatterplotLayer + main range + // PathLayer. Diagnostic toggle to isolate whether residual lag is layer + // complexity vs the WebView itself. + let outLayers = layers; + if (perfMode && isInteracting) { + const KEEP = new Set(["fixed-assets", "range-assets"]); + outLayers = layers.filter((l) => + KEEP.has(((l as { id?: string }).id ?? "")), + ); + } + overlay.setProps({ layers: outLayers }); }, [ fixedPoints, rangePaths, @@ -1356,9 +1402,10 @@ export function MapView({ gotoPin, selectedMetadataVideo, metadataTracks, - metadataTrack, - layerVisibility, - viewport.zoom, + anchorIds, + zoomBand, + isInteracting, + perfMode, ]); // Manage draggable HTML markers + per-vertex popup for the actively-edited asset. diff --git a/src/tauriShim.ts b/src/tauriShim.ts new file mode 100644 index 0000000..73bae60 --- /dev/null +++ b/src/tauriShim.ts @@ -0,0 +1,166 @@ +// Tauri / browser-mode shim. +// +// In a real Tauri build, `__TAURI_INTERNALS__` is injected by the runtime and +// `invoke()` round-trips to Rust. When the same Vite bundle is opened in a +// plain browser (Firefox / Chrome) for the WebView A/B test, that global is +// missing and every invoke would throw. This shim short-circuits a small set +// of read-only commands with synthetic data so the map renders against +// realistic geometry — purely for measuring pan / zoom FPS across WebViews. + +import { invoke as tauriInvoke } from "@tauri-apps/api/core"; + +declare global { + interface Window { + __TAURI_INTERNALS__?: unknown; + } +} + +export const IS_BROWSER_MODE = + typeof window !== "undefined" && !window.__TAURI_INTERNALS__; + +type FakeAsset = { + id: number; + row_id: string; + asset_type: "fixed" | "range"; + asset_name: string; + video_name: string; + side: string | null; + lat: number | null; + lng: number | null; + start_lat: number | null; + start_lng: number | null; + end_lat: number | null; + end_lng: number | null; + orig_lat: number | null; + orig_lng: number | null; + orig_start_lat: number | null; + orig_start_lng: number | null; + orig_end_lat: number | null; + orig_end_lng: number | null; + image_path: string | null; + image_path1: string | null; + image_path2: string | null; + image_path3: string | null; + deleted: 0 | 1; + in_scope: 0 | 1; + modified: 0 | 1; + link_pair_id: number | null; + link_locked: number | null; +}; + +// Centred near Hyderabad to match the production dataset the user showed +// earlier. Counts and geographic spread tuned to mimic a realistic viewport. +function makeFakeAssets(): FakeAsset[] { + const N = 5000; + const center: [number, number] = [78.347, 17.398]; + const names = ["Streetlight", "Traffic_Sign", "Hydrant", "Pole", "Tree"]; + const sides: Array<"Left" | "Right" | null> = ["Left", "Right", null]; + const out: FakeAsset[] = []; + for (let i = 0; i < N; i++) { + const dLat = (Math.random() - 0.5) * 0.06; + const dLng = (Math.random() - 0.5) * 0.06; + out.push({ + id: i + 1, + row_id: `browser_${i}`, + asset_type: "fixed", + asset_name: names[i % names.length], + video_name: `browser_video_${i % 6}`, + side: sides[i % 3], + lat: center[1] + dLat, + lng: center[0] + dLng, + start_lat: null, + start_lng: null, + end_lat: null, + end_lng: null, + orig_lat: center[1] + dLat, + orig_lng: center[0] + dLng, + orig_start_lat: null, + orig_start_lng: null, + orig_end_lat: null, + orig_end_lng: null, + image_path: null, + image_path1: null, + image_path2: null, + image_path3: null, + deleted: 0, + in_scope: 1, + modified: 0, + link_pair_id: null, + link_locked: null, + }); + } + return out; +} + +const FAKE_ASSETS: FakeAsset[] = IS_BROWSER_MODE ? makeFakeAssets() : []; + +type AnyArgs = Record | undefined; +type Stub = (args?: AnyArgs) => unknown; + +const STUBS: Record = { + list_users: () => [ + { + id: 1, + username: "browser-test", + display_name: "Browser Test", + created_at: new Date().toISOString(), + }, + ], + create_user: (args) => ({ + id: 2, + username: String(args?.username ?? "browser"), + display_name: String(args?.displayName ?? "Browser"), + created_at: new Date().toISOString(), + }), + set_current_user: () => null, + list_assets: () => FAKE_ASSETS, + get_assets_in_bbox: (args) => { + const minLat = Number(args?.minLat ?? -90); + const maxLat = Number(args?.maxLat ?? 90); + const minLng = Number(args?.minLng ?? -180); + const maxLng = Number(args?.maxLng ?? 180); + return FAKE_ASSETS.filter( + (a) => + a.lat !== null && + a.lng !== null && + a.lat >= minLat && + a.lat <= maxLat && + a.lng >= minLng && + a.lng <= maxLng, + ); + }, + data_source_counts: () => ({ + fixed_assets: FAKE_ASSETS.length, + range_assets: 0, + scope_polygons: 0, + osm_roads: 0, + metadata_polylines: 0, + }), + list_video_names: () => + Array.from(new Set(FAKE_ASSETS.map((a) => a.video_name))), + list_asset_names: () => + Array.from(new Set(FAKE_ASSETS.map((a) => a.asset_name))), + list_classes: () => ["Streetlight", "Traffic_Sign", "Hydrant", "Pole", "Tree"], + list_recent_actions: () => [], + get_scope_polygons: () => [], + read_video_polylines_json: () => ({}), + read_metadata_kml: () => [], + list_osm_roads: () => ({ type: "FeatureCollection", features: [] }), +}; + +export function invoke( + cmd: string, + args?: AnyArgs, +): Promise { + if (!IS_BROWSER_MODE) { + return tauriInvoke(cmd, args as Record); + } + const stub = STUBS[cmd]; + if (stub) { + return Promise.resolve(stub(args)) as Promise; + } + // Anything we haven't stubbed (mutations, file dialogs, exports, OSM I/O) + // is a no-op in browser mode. + console.warn(`[browser-mode] invoke(${cmd}) is not stubbed — returning null`); + return Promise.resolve(null as unknown as T); +}