performance improvement

This commit is contained in:
2026-04-28 16:17:46 +05:30
parent 1f70e01d89
commit ace6c4a4f3
4 changed files with 400 additions and 63 deletions

View File

@@ -1,11 +1,12 @@
import { useEffect, useMemo, useRef, useState } from "react"; 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"; import { open, save } from "@tauri-apps/plugin-dialog";
const IMAGE_FOLDER_KEY = "kml_map_tool.image_folder"; const IMAGE_FOLDER_KEY = "kml_map_tool.image_folder";
const POPUP_SIZE_KEY = "kml_map_tool.popup_size"; const POPUP_SIZE_KEY = "kml_map_tool.popup_size";
const POPUP_ENABLED_KEY = "kml_map_tool.popup_enabled"; const POPUP_ENABLED_KEY = "kml_map_tool.popup_enabled";
const POPUP_ASPECT_KEY = "kml_map_tool.popup_aspect"; const POPUP_ASPECT_KEY = "kml_map_tool.popup_aspect";
const PERF_MODE_KEY = "kml_map_tool.perf_mode";
type PopupSize = "S" | "M" | "L" | "XL"; type PopupSize = "S" | "M" | "L" | "XL";
const POPUP_WIDTH_PX: Record<PopupSize, number> = { const POPUP_WIDTH_PX: Record<PopupSize, number> = {
@@ -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 (
<div
style={{
position: "fixed",
top: 8,
left: "50%",
transform: "translateX(-50%)",
zIndex: 9000,
background: "rgba(0,0,0,0.75)",
color: fps >= 50 ? "#7CFC00" : fps >= 30 ? "#FFD700" : "#FF6B6B",
padding: "4px 10px",
borderRadius: 4,
fontFamily: "monospace",
fontSize: 12,
fontWeight: 700,
pointerEvents: "none",
}}
>
{fps} fps · perf
</div>
);
}
function AppShell({ function AppShell({
currentUser, currentUser,
onLogout, onLogout,
@@ -111,7 +156,11 @@ function AppShell({
currentUser: CurrentUser; currentUser: CurrentUser;
onLogout: () => void; onLogout: () => void;
}) { }) {
const [viewport, setViewport] = useState<Viewport>(INITIAL_VIEWPORT); const [viewport, setViewport] = useState<Viewport>(
IS_BROWSER_MODE
? { center: [78.347, 17.398], zoom: 14 }
: INITIAL_VIEWPORT,
);
const [bounds, setBounds] = useState<BoundsBox | null>(null); const [bounds, setBounds] = useState<BoundsBox | null>(null);
const [primaryId, setPrimaryId] = useState<string>(DEFAULT_BASEMAP_ID); const [primaryId, setPrimaryId] = useState<string>(DEFAULT_BASEMAP_ID);
const [secondaryId, setSecondaryId] = useState<string>("esri-imagery"); const [secondaryId, setSecondaryId] = useState<string>("esri-imagery");
@@ -182,6 +231,13 @@ function AppShell({
const v = window.localStorage.getItem(POPUP_ASPECT_KEY) as PopupAspect | null; const v = window.localStorage.getItem(POPUP_ASPECT_KEY) as PopupAspect | null;
return v && v in POPUP_ASPECT_VAL ? v : "4:3"; 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<boolean>(() => {
if (typeof window === "undefined") return false;
return window.localStorage.getItem(PERF_MODE_KEY) === "1";
});
const [filters, setFilters] = useState<Filters>({ const [filters, setFilters] = useState<Filters>({
videos: new Set(), videos: new Set(),
sides: new Set(), sides: new Set(),
@@ -323,6 +379,9 @@ function AppShell({
useEffect(() => { useEffect(() => {
window.localStorage.setItem(POPUP_ENABLED_KEY, popupEnabled ? "1" : "0"); window.localStorage.setItem(POPUP_ENABLED_KEY, popupEnabled ? "1" : "0");
}, [popupEnabled]); }, [popupEnabled]);
useEffect(() => {
window.localStorage.setItem(PERF_MODE_KEY, perfMode ? "1" : "0");
}, [perfMode]);
// Counts on the current bbox slice (live update as user pans/zooms). // Counts on the current bbox slice (live update as user pans/zooms).
const counts = useMemo(() => { const counts = useMemo(() => {
@@ -887,15 +946,43 @@ function AppShell({
const list = await invoke< const list = await invoke<
Array<{ id: number; name: string | null; geojson: string }> Array<{ id: number; name: string | null; geojson: string }>
>("get_osm_roads"); >("get_osm_roads");
setOsmRoads({ let mnLat = Infinity;
type: "FeatureCollection", let mxLat = -Infinity;
features: list.map((r) => ({ let mnLng = Infinity;
type: "Feature", let mxLng = -Infinity;
geometry: JSON.parse(r.geojson), 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 }, 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() { async function refreshAssetNames() {
@@ -1219,8 +1306,30 @@ function AppShell({
} }
setStatus("Importing OSM roads…"); setStatus("Importing OSM roads…");
const count = await invoke<number>("import_osm_geojson", args); const count = await invoke<number>("import_osm_geojson", args);
await refreshOsmRoads(); const { bounds } = await refreshOsmRoads();
setStatus(`Loaded ${count} road segment(s)`); // 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) { } catch (e) {
setStatus(`OSM import failed: ${e}`); setStatus(`OSM import failed: ${e}`);
} finally { } finally {
@@ -2073,6 +2182,7 @@ function AppShell({
return ( return (
<div className="app"> <div className="app">
{perfMode && <FpsCounter />}
<div className="map-area"> <div className="map-area">
<MapView <MapView
basemapId={primaryId} basemapId={primaryId}
@@ -2158,6 +2268,7 @@ function AppShell({
onPlaceAt={handlePlaceAt} onPlaceAt={handlePlaceAt}
imageFolder={imageFolder} imageFolder={imageFolder}
popupEnabled={popupEnabled} popupEnabled={popupEnabled}
perfMode={perfMode}
anchorIds={Array.from(anchors.keys())} anchorIds={Array.from(anchors.keys())}
colorBy={colorBy} colorBy={colorBy}
osmEditMode={osmEditMode} osmEditMode={osmEditMode}
@@ -2220,6 +2331,7 @@ function AppShell({
onPositionChange={handlePositionChange} onPositionChange={handlePositionChange}
imageFolder={imageFolder} imageFolder={imageFolder}
popupEnabled={popupEnabled} popupEnabled={popupEnabled}
perfMode={perfMode}
anchorIds={Array.from(anchors.keys())} anchorIds={Array.from(anchors.keys())}
colorBy={ colorBy={
new Set(filteredAssets.map((a) => displayVideo(a.video_name))) new Set(filteredAssets.map((a) => displayVideo(a.video_name)))
@@ -2864,6 +2976,18 @@ function AppShell({
/> />
Show image popup when an asset is selected Show image popup when an asset is selected
</label> </label>
<h4 style={{ marginTop: 12 }}>Diagnostics</h4>
<label style={{ display: "flex", alignItems: "center", gap: 6 }}>
<input
type="checkbox"
checked={perfMode}
onChange={(e) => setPerfMode(e.target.checked)}
/>
Performance mode: while panning/zooming, show only basemap +
asset markers + range paths (hides roads, scope, metadata, links,
labels). Diagnostic leave off for normal use.
</label>
<h4 style={{ marginTop: 12 }}>Popup width</h4> <h4 style={{ marginTop: 12 }}>Popup width</h4>
<div className="size-picker"> <div className="size-picker">
{(["S", "M", "L", "XL"] as PopupSize[]).map((s) => { {(["S", "M", "L", "XL"] as PopupSize[]).map((s) => {

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "./tauriShim";
type User = { type User = {
id: number; id: number;

View File

@@ -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 maplibregl, { Map as MapLibreMap, LngLatLike } from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
// @ts-expect-error: TS does not see the re-export-of-default in @deck.gl/mapbox's // @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; onLinkClick?: (idA: number, idB: number) => void;
onMetadataLineClick?: (videoName: string) => void; onMetadataLineClick?: (videoName: string) => void;
popupEnabled?: boolean; popupEnabled?: boolean;
perfMode?: 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;
onPlaceAt?: (lat: number, lng: number) => void; onPlaceAt?: (lat: number, lng: number) => void;
@@ -128,6 +129,7 @@ export function MapView({
onLinkClick, onLinkClick,
onMetadataLineClick, onMetadataLineClick,
popupEnabled = true, popupEnabled = true,
perfMode = false,
onPositionChange, onPositionChange,
placeMode = false, placeMode = false,
onPlaceAt, onPlaceAt,
@@ -147,6 +149,11 @@ export function MapView({
highlightedIds = [], highlightedIds = [],
anchorIds = [], anchorIds = [],
}: Props) { }: 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<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<MapLibreMap | null>(null); const mapRef = useRef<MapLibreMap | null>(null);
const overlayRef = useRef<MapboxOverlay | null>(null); const overlayRef = useRef<MapboxOverlay | null>(null);
@@ -189,11 +196,15 @@ export function MapView({
}); });
mapRef.current = map; mapRef.current = map;
map.on("move", (e) => { map.on("movestart", () => setIsInteracting(true));
if (!e.originalEvent) return;
const c = map.getCenter(); // Intentionally NOT mirroring every "move" tick to App state. The map
onUserMoveRef.current({ center: [c.lng, c.lat], zoom: map.getZoom() }); // 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) => { map.on("contextmenu", (e) => {
const overlay = overlayRef.current; const overlay = overlayRef.current;
@@ -212,6 +223,11 @@ export function MapView({
}); });
map.on("moveend", () => { 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(); const b = map.getBounds();
onMoveEndRef.current?.({ onMoveEndRef.current?.({
bounds: { bounds: {
@@ -382,9 +398,23 @@ export function MapView({
}; };
}, [scopePolygons]); }, [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(() => { useEffect(() => {
const overlay = overlayRef.current; const overlay = overlayRef.current;
if (!overlay) return; if (!overlay) return;
if (import.meta.env.DEV) {
console.count("deck layer rebuild");
}
const colorKey = (a: Asset) => const colorKey = (a: Asset) =>
colorBy === "asset" ? `${a.video_name}#${a.id}` : a.video_name; 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. // Zoom-based decimation: keep ~20 points at z≤10, scaling up with zoom.
const targetPoints = Math.min( const targetPoints = Math.min(
400, 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 m = mapRef.current;
const b = m ? m.getBounds() : null; 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 // clean and snappy. Density scales with zoom so the user sees a few
// arrows when zoomed out and many when zoomed in. // arrows when zoomed out and many when zoomed in.
const arrowEntries = const arrowEntries =
selectedMetadataVideo !== null selectedMetadataVideo !== null && !isInteracting
? trackEntries.filter((e) => e.name === selectedMetadataVideo) ? trackEntries.filter((e) => e.name === selectedMetadataVideo)
: []; : [];
if (arrowEntries.length > 0) { if (arrowEntries.length > 0) {
@@ -664,7 +694,7 @@ export function MapView({
(typeof viewport.center?.[1] === "number" ? viewport.center[1] : 0) * (typeof viewport.center?.[1] === "number" ? viewport.center[1] : 0) *
(Math.PI / 180); (Math.PI / 180);
const mPerPx = 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 spacingM = Math.max(20, mPerPx * 80);
const arrows: Array<{ const arrows: Array<{
pos: [number, number]; pos: [number, number];
@@ -774,8 +804,9 @@ export function MapView({
}), }),
); );
// Selected-track decorations: name badge above midpoint + Start/End // Selected-track decorations: name badge above midpoint + Start/End
// markers at the track's first and last points. // markers at the track's first and last points. Skipped during pan /
if (selectedMetadataVideo !== null) { // zoom — TextLayer is the most expensive overlay on weak GPUs.
if (selectedMetadataVideo !== null && !isInteracting) {
const sel = trackEntries.find( const sel = trackEntries.find(
(e) => e.name === selectedMetadataVideo, (e) => e.name === selectedMetadataVideo,
); );
@@ -900,7 +931,7 @@ export function MapView({
pickable: false, pickable: false,
}), }),
); );
if (arrows.length > 0) { if (arrows.length > 0 && !isInteracting) {
layers.push( layers.push(
new TextLayer<{ pos: [number, number]; angle: number }>({ new TextLayer<{ pos: [number, number]; angle: number }>({
id: "metadata-arrows-bg", id: "metadata-arrows-bg",
@@ -955,24 +986,26 @@ export function MapView({
pickable: false, pickable: false,
}), }),
); );
layers.push( if (!isInteracting) {
new TextLayer<{ pos: [number, number]; kind: "S" | "E" }>({ layers.push(
id: "metadata-endpoint-labels", new TextLayer<{ pos: [number, number]; kind: "S" | "E" }>({
data: [ id: "metadata-endpoint-labels",
{ pos: start, kind: "S" as const }, data: [
{ pos: end, kind: "E" as const }, { pos: start, kind: "S" as const },
], { pos: end, kind: "E" as const },
getPosition: (d) => d.pos, ],
getText: (d) => d.kind, getPosition: (d) => d.pos,
getColor: [255, 255, 255, 255], getText: (d) => d.kind,
getSize: 11, getColor: [255, 255, 255, 255],
sizeUnits: "pixels", getSize: 11,
getTextAnchor: "middle", sizeUnits: "pixels",
getAlignmentBaseline: "center", getTextAnchor: "middle",
fontWeight: "bold", getAlignmentBaseline: "center",
pickable: false, fontWeight: "bold",
}), pickable: false,
); }),
);
}
} }
if (layerVisibility.scope) { if (layerVisibility.scope) {
layers.push( layers.push(
@@ -1203,23 +1236,25 @@ export function MapView({
}); });
} }
if (linkPaths.length > 0) { if (linkPaths.length > 0) {
layers.push( if (!isInteracting) {
new PathLayer<{ layers.push(
path: Array<[number, number]>; new PathLayer<{
locked: boolean; path: Array<[number, number]>;
idA: number; locked: boolean;
idB: number; idA: number;
}>({ idB: number;
id: "asset-links-halo", }>({
data: linkPaths, id: "asset-links-halo",
getPath: (d) => d.path, data: linkPaths,
getColor: [255, 255, 255, 200], getPath: (d) => d.path,
getWidth: 9, getColor: [255, 255, 255, 200],
widthUnits: "pixels", getWidth: 9,
widthMinPixels: 7, widthUnits: "pixels",
pickable: false, widthMinPixels: 7,
}), pickable: false,
); }),
);
}
layers.push( layers.push(
new PathLayer<{ new PathLayer<{
path: Array<[number, number]>; 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, fixedPoints,
rangePaths, rangePaths,
@@ -1356,9 +1402,10 @@ export function MapView({
gotoPin, gotoPin,
selectedMetadataVideo, selectedMetadataVideo,
metadataTracks, metadataTracks,
metadataTrack, anchorIds,
layerVisibility, zoomBand,
viewport.zoom, isInteracting,
perfMode,
]); ]);
// Manage draggable HTML markers + per-vertex popup for the actively-edited asset. // Manage draggable HTML markers + per-vertex popup for the actively-edited asset.

166
src/tauriShim.ts Normal file
View File

@@ -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<string, unknown> | undefined;
type Stub = (args?: AnyArgs) => unknown;
const STUBS: Record<string, Stub> = {
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<T = unknown>(
cmd: string,
args?: AnyArgs,
): Promise<T> {
if (!IS_BROWSER_MODE) {
return tauriInvoke<T>(cmd, args as Record<string, unknown>);
}
const stub = STUBS[cmd];
if (stub) {
return Promise.resolve(stub(args)) as Promise<T>;
}
// 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);
}