performance improvement
This commit is contained in:
144
src/App.tsx
144
src/App.tsx
@@ -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();
|
||||||
|
// 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)`);
|
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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,6 +986,7 @@ export function MapView({
|
|||||||
pickable: false,
|
pickable: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
if (!isInteracting) {
|
||||||
layers.push(
|
layers.push(
|
||||||
new TextLayer<{ pos: [number, number]; kind: "S" | "E" }>({
|
new TextLayer<{ pos: [number, number]; kind: "S" | "E" }>({
|
||||||
id: "metadata-endpoint-labels",
|
id: "metadata-endpoint-labels",
|
||||||
@@ -974,6 +1006,7 @@ export function MapView({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (layerVisibility.scope) {
|
if (layerVisibility.scope) {
|
||||||
layers.push(
|
layers.push(
|
||||||
new GeoJsonLayer({
|
new GeoJsonLayer({
|
||||||
@@ -1203,6 +1236,7 @@ export function MapView({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (linkPaths.length > 0) {
|
if (linkPaths.length > 0) {
|
||||||
|
if (!isInteracting) {
|
||||||
layers.push(
|
layers.push(
|
||||||
new PathLayer<{
|
new PathLayer<{
|
||||||
path: Array<[number, number]>;
|
path: Array<[number, number]>;
|
||||||
@@ -1220,6 +1254,7 @@ export function MapView({
|
|||||||
pickable: false,
|
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
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