diff --git a/mobile/src/assets/webview/gaussian-splats.html b/mobile/src/assets/webview/gaussian-splats.html index e69de29..16a398e 100644 --- a/mobile/src/assets/webview/gaussian-splats.html +++ b/mobile/src/assets/webview/gaussian-splats.html @@ -0,0 +1,585 @@ + + + + + + + WiFi DensePose Splat Viewer + + + +
+ + + + + + + diff --git a/mobile/src/assets/webview/mat-dashboard.html b/mobile/src/assets/webview/mat-dashboard.html index e69de29..eafd444 100644 --- a/mobile/src/assets/webview/mat-dashboard.html +++ b/mobile/src/assets/webview/mat-dashboard.html @@ -0,0 +1,505 @@ + + + + + + MAT Dashboard + + + +
+
Initializing MAT dashboard...
+ +
+ + + + diff --git a/mobile/src/components/GaugeArc.tsx b/mobile/src/components/GaugeArc.tsx index 033491c..08050de 100644 --- a/mobile/src/components/GaugeArc.tsx +++ b/mobile/src/components/GaugeArc.tsx @@ -1,37 +1,58 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; -import Animated, { useAnimatedProps, useSharedValue, withTiming } from 'react-native-reanimated'; +import Animated, { interpolateColor, useAnimatedProps, useSharedValue, withSpring } from 'react-native-reanimated'; import Svg, { Circle, G, Text as SvgText } from 'react-native-svg'; type GaugeArcProps = { value: number; + min?: number; max: number; label: string; unit: string; color: string; + colorTo?: string; size?: number; }; const AnimatedCircle = Animated.createAnimatedComponent(Circle); -export const GaugeArc = ({ value, max, label, unit, color, size = 140 }: GaugeArcProps) => { +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)); + +export const GaugeArc = ({ value, min = 0, max, label, unit, color, colorTo, size = 140 }: GaugeArcProps) => { const radius = (size - 20) / 2; const circumference = 2 * Math.PI * radius; const arcLength = circumference * 0.75; const strokeWidth = 12; const progress = useSharedValue(0); - const normalized = Math.max(0, Math.min(max > 0 ? value / max : 0, 1)); - const displayText = `${value.toFixed(1)} ${unit}`; + const normalized = useMemo(() => { + const span = max - min; + const safeSpan = span > 0 ? span : 1; + return clamp((value - min) / safeSpan, 0, 1); + }, [value, min, max]); + + const displayValue = useMemo(() => { + if (!Number.isFinite(value)) { + return '--'; + } + return `${Math.max(min, Math.min(max, value)).toFixed(1)} ${unit}`; + }, [max, min, unit, value]); useEffect(() => { - progress.value = withTiming(normalized, { duration: 600 }); + progress.value = withSpring(normalized, { + damping: 16, + stiffness: 140, + mass: 1, + }); }, [normalized, progress]); const animatedStroke = useAnimatedProps(() => { const dashOffset = arcLength - arcLength * progress.value; + const strokeColor = colorTo ? interpolateColor(progress.value, [0, 1], [color, colorTo]) : color; + return { strokeDashoffset: dashOffset, + stroke: strokeColor, }; }); @@ -63,20 +84,20 @@ export const GaugeArc = ({ value, max, label, unit, color, size = 140 }: GaugeAr - {displayText} + {displayValue} void; + onError: () => void; + webViewRef: RefObject; + onLayout?: (event: LayoutChangeEvent) => void; +}; + +export const GaussianSplatWebView = ({ + onMessage, + onError, + webViewRef, + onLayout, +}: GaussianSplatWebViewProps) => { + const html = typeof GAUSSIAN_SPLATS_HTML === 'string' ? GAUSSIAN_SPLATS_HTML : ''; + + return ( + + ); +}; + +const styles = StyleSheet.create({ + webView: { + flex: 1, + backgroundColor: '#0A0E1A', + }, +}); diff --git a/mobile/src/screens/LiveScreen/LiveHUD.tsx b/mobile/src/screens/LiveScreen/LiveHUD.tsx index e69de29..19c93ac 100644 --- a/mobile/src/screens/LiveScreen/LiveHUD.tsx +++ b/mobile/src/screens/LiveScreen/LiveHUD.tsx @@ -0,0 +1,164 @@ +import { Pressable, StyleSheet, View } from 'react-native'; +import { memo, useCallback, useState } from 'react'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import { StatusDot } from '@/components/StatusDot'; +import { ModeBadge } from '@/components/ModeBadge'; +import { ThemedText } from '@/components/ThemedText'; +import { formatConfidence, formatRssi } from '@/utils/formatters'; +import { colors, spacing } from '@/theme'; +import type { ConnectionStatus } from '@/types/sensing'; + +type LiveMode = 'LIVE' | 'SIM' | 'RSSI'; + +type LiveHUDProps = { + rssi?: number; + connectionStatus: ConnectionStatus; + fps: number; + confidence: number; + personCount: number; + mode: LiveMode; +}; + +const statusTextMap: Record = { + connected: 'Connected', + simulated: 'Simulated', + connecting: 'Connecting', + disconnected: 'Disconnected', +}; + +const statusDotStatusMap: Record = { + connected: 'connected', + simulated: 'simulated', + connecting: 'connecting', + disconnected: 'disconnected', +}; + +export const LiveHUD = memo( + ({ rssi, connectionStatus, fps, confidence, personCount, mode }: LiveHUDProps) => { + const [panelVisible, setPanelVisible] = useState(true); + const panelAlpha = useSharedValue(1); + + const togglePanel = useCallback(() => { + const next = !panelVisible; + setPanelVisible(next); + panelAlpha.value = withTiming(next ? 1 : 0, { duration: 220 }); + }, [panelAlpha, panelVisible]); + + const animatedPanelStyle = useAnimatedStyle(() => ({ + opacity: panelAlpha.value, + })); + + const statusText = statusTextMap[connectionStatus]; + + return ( + + + {/* App title */} + + + WiFi-DensePose + + + + {/* Status + FPS */} + + + + + {statusText} + + + {fps > 0 && ( + + {fps} FPS + + )} + + + {/* Bottom panel */} + + + RSSI + + {formatRssi(rssi)} + + + + + + + + + Confidence + + {formatConfidence(confidence)} + + People: {personCount} + + + + + ); + }, +); + +const styles = StyleSheet.create({ + topLeft: { + position: 'absolute', + top: spacing.md, + left: spacing.md, + }, + appTitle: { + color: colors.textPrimary, + }, + topRight: { + position: 'absolute', + top: spacing.md, + right: spacing.md, + alignItems: 'flex-end', + gap: 4, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + statusText: { + color: colors.textPrimary, + }, + bottomPanel: { + position: 'absolute', + left: spacing.sm, + right: spacing.sm, + bottom: spacing.sm, + minHeight: 72, + borderRadius: 12, + backgroundColor: 'rgba(10,14,26,0.72)', + borderWidth: 1, + borderColor: 'rgba(50,184,198,0.35)', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + bottomCell: { + flex: 1, + alignItems: 'center', + }, + bottomCellRight: { + flex: 1, + alignItems: 'flex-end', + }, + bigValue: { + color: colors.accent, + marginTop: 2, + marginBottom: 2, + }, + metaText: { + color: colors.textPrimary, + marginBottom: 4, + }, +}); + +LiveHUD.displayName = 'LiveHUD'; diff --git a/mobile/src/screens/LiveScreen/index.tsx b/mobile/src/screens/LiveScreen/index.tsx index e69de29..6589863 100644 --- a/mobile/src/screens/LiveScreen/index.tsx +++ b/mobile/src/screens/LiveScreen/index.tsx @@ -0,0 +1,215 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Button, LayoutChangeEvent, StyleSheet, View } from 'react-native'; +import type { WebView } from 'react-native-webview'; +import type { WebViewMessageEvent } from 'react-native-webview'; +import { ErrorBoundary } from '@/components/ErrorBoundary'; +import { LoadingSpinner } from '@/components/LoadingSpinner'; +import { ThemedText } from '@/components/ThemedText'; +import { ThemedView } from '@/components/ThemedView'; +import { usePoseStream } from '@/hooks/usePoseStream'; +import { colors, spacing } from '@/theme'; +import type { ConnectionStatus, SensingFrame } from '@/types/sensing'; +import { useGaussianBridge } from './useGaussianBridge'; +import { GaussianSplatWebView } from './GaussianSplatWebView'; +import { LiveHUD } from './LiveHUD'; + +type LiveMode = 'LIVE' | 'SIM' | 'RSSI'; + +const getMode = ( + status: ConnectionStatus, + isSimulated: boolean, + frame: SensingFrame | null, +): LiveMode => { + if (isSimulated || frame?.source === 'simulated') { + return 'SIM'; + } + + if (status === 'connected') { + return 'LIVE'; + } + + return 'RSSI'; +}; + +const dispatchWebViewMessage = (webViewRef: { current: WebView | null }, message: unknown) => { + const webView = webViewRef.current; + if (!webView) { + return; + } + + const payload = JSON.stringify(message); + webView.injectJavaScript( + `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify(payload)} })); true;`, + ); +}; + +export const LiveScreen = () => { + const webViewRef = useRef(null); + const { lastFrame, connectionStatus, isSimulated } = usePoseStream(); + const bridge = useGaussianBridge(webViewRef); + + const [webError, setWebError] = useState(null); + const [viewerKey, setViewerKey] = useState(0); + const sendTimeoutRef = useRef | null>(null); + const pendingFrameRef = useRef(null); + const lastSentAtRef = useRef(0); + + const clearSendTimeout = useCallback(() => { + if (!sendTimeoutRef.current) { + return; + } + clearTimeout(sendTimeoutRef.current); + sendTimeoutRef.current = null; + }, []); + + useEffect(() => { + if (!lastFrame) { + return; + } + + pendingFrameRef.current = lastFrame; + const now = Date.now(); + + const flush = () => { + if (!bridge.isReady || !pendingFrameRef.current) { + return; + } + + bridge.sendFrame(pendingFrameRef.current); + lastSentAtRef.current = Date.now(); + pendingFrameRef.current = null; + }; + + const waitMs = Math.max(0, 500 - (now - lastSentAtRef.current)); + + if (waitMs <= 0) { + flush(); + return; + } + + clearSendTimeout(); + sendTimeoutRef.current = setTimeout(() => { + sendTimeoutRef.current = null; + flush(); + }, waitMs); + + return () => { + clearSendTimeout(); + }; + }, [bridge.isReady, lastFrame, bridge.sendFrame, clearSendTimeout]); + + useEffect(() => { + return () => { + dispatchWebViewMessage(webViewRef, { type: 'DISPOSE' }); + clearSendTimeout(); + pendingFrameRef.current = null; + }; + }, [clearSendTimeout]); + + const onMessage = useCallback( + (event: WebViewMessageEvent) => { + bridge.onMessage(event); + }, + [bridge], + ); + + const onLayout = useCallback((event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + if (width <= 0 || height <= 0 || Number.isNaN(width) || Number.isNaN(height)) { + return; + } + + dispatchWebViewMessage(webViewRef, { + type: 'RESIZE', + payload: { + width: Math.max(1, Math.floor(width)), + height: Math.max(1, Math.floor(height)), + }, + }); + }, []); + + const handleWebError = useCallback(() => { + setWebError('Live renderer failed to initialize'); + }, []); + + const handleRetry = useCallback(() => { + setWebError(null); + bridge.reset(); + setViewerKey((value) => value + 1); + }, [bridge]); + + const rssi = lastFrame?.features?.mean_rssi; + const personCount = lastFrame?.classification?.presence ? 1 : 0; + const mode = getMode(connectionStatus, isSimulated, lastFrame); + + if (webError || bridge.error) { + return ( + + Live visualization failed + + {webError ?? bridge.error} + +