performance improvement
This commit is contained in:
146
src/App.tsx
146
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<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({
|
||||
currentUser,
|
||||
onLogout,
|
||||
@@ -111,7 +156,11 @@ function AppShell({
|
||||
currentUser: CurrentUser;
|
||||
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 [primaryId, setPrimaryId] = useState<string>(DEFAULT_BASEMAP_ID);
|
||||
const [secondaryId, setSecondaryId] = useState<string>("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<boolean>(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.localStorage.getItem(PERF_MODE_KEY) === "1";
|
||||
});
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
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<number>("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 (
|
||||
<div className="app">
|
||||
{perfMode && <FpsCounter />}
|
||||
<div className="map-area">
|
||||
<MapView
|
||||
basemapId={primaryId}
|
||||
@@ -2158,6 +2268,7 @@ function AppShell({
|
||||
onPlaceAt={handlePlaceAt}
|
||||
imageFolder={imageFolder}
|
||||
popupEnabled={popupEnabled}
|
||||
perfMode={perfMode}
|
||||
anchorIds={Array.from(anchors.keys())}
|
||||
colorBy={colorBy}
|
||||
osmEditMode={osmEditMode}
|
||||
@@ -2220,6 +2331,7 @@ function AppShell({
|
||||
onPositionChange={handlePositionChange}
|
||||
imageFolder={imageFolder}
|
||||
popupEnabled={popupEnabled}
|
||||
perfMode={perfMode}
|
||||
anchorIds={Array.from(anchors.keys())}
|
||||
colorBy={
|
||||
new Set(filteredAssets.map((a) => displayVideo(a.video_name)))
|
||||
@@ -2864,6 +2976,18 @@ function AppShell({
|
||||
/>
|
||||
Show image popup when an asset is selected
|
||||
</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>
|
||||
<div className="size-picker">
|
||||
{(["S", "M", "L", "XL"] as PopupSize[]).map((s) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { invoke } from "./tauriShim";
|
||||
|
||||
type User = {
|
||||
id: number;
|
||||
|
||||
149
src/MapView.tsx
149
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<HTMLDivElement>(null);
|
||||
const mapRef = useRef<MapLibreMap | null>(null);
|
||||
const overlayRef = useRef<MapboxOverlay | null>(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.
|
||||
|
||||
166
src/tauriShim.ts
Normal file
166
src/tauriShim.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user