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}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {!bridge.isReady && (
+
+
+
+ Loading live renderer
+
+
+ )}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bg,
+ },
+ loadingWrap: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: colors.bg,
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: spacing.md,
+ },
+ loadingText: {
+ color: colors.textSecondary,
+ },
+ fallbackWrap: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: spacing.md,
+ padding: spacing.lg,
+ },
+ errorText: {
+ textAlign: 'center',
+ },
+});
diff --git a/mobile/src/screens/LiveScreen/useGaussianBridge.ts b/mobile/src/screens/LiveScreen/useGaussianBridge.ts
index e69de29..c75929c 100644
--- a/mobile/src/screens/LiveScreen/useGaussianBridge.ts
+++ b/mobile/src/screens/LiveScreen/useGaussianBridge.ts
@@ -0,0 +1,97 @@
+import { useCallback, useState } from 'react';
+import type { RefObject } from 'react';
+import type { WebViewMessageEvent } from 'react-native-webview';
+import { WebView } from 'react-native-webview';
+import type { SensingFrame } from '@/types/sensing';
+
+export type GaussianBridgeMessageType = 'READY' | 'FPS_TICK' | 'ERROR';
+
+type BridgeMessage = {
+ type: GaussianBridgeMessageType;
+ payload?: {
+ fps?: number;
+ message?: string;
+ };
+};
+
+const toJsonScript = (message: unknown): string => {
+ const serialized = JSON.stringify(message);
+ return `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify(serialized)} })); true;`;
+};
+
+export const useGaussianBridge = (webViewRef: RefObject) => {
+ const [isReady, setIsReady] = useState(false);
+ const [fps, setFps] = useState(0);
+ const [error, setError] = useState(null);
+
+ const send = useCallback((message: unknown) => {
+ const webView = webViewRef.current;
+ if (!webView) {
+ return;
+ }
+
+ webView.injectJavaScript(toJsonScript(message));
+ }, [webViewRef]);
+
+ const sendFrame = useCallback(
+ (frame: SensingFrame) => {
+ send({
+ type: 'FRAME_UPDATE',
+ payload: frame,
+ });
+ },
+ [send],
+ );
+
+ const onMessage = useCallback((event: WebViewMessageEvent) => {
+ let parsed: BridgeMessage | null = null;
+ const raw = event.nativeEvent.data;
+
+ if (typeof raw === 'string') {
+ try {
+ parsed = JSON.parse(raw) as BridgeMessage;
+ } catch {
+ setError('Invalid bridge message format');
+ return;
+ }
+ } else if (typeof raw === 'object' && raw !== null) {
+ parsed = raw as BridgeMessage;
+ }
+
+ if (!parsed) {
+ return;
+ }
+
+ if (parsed.type === 'READY') {
+ setIsReady(true);
+ setError(null);
+ return;
+ }
+
+ if (parsed.type === 'FPS_TICK') {
+ const fpsValue = parsed.payload?.fps;
+ if (typeof fpsValue === 'number' && Number.isFinite(fpsValue)) {
+ setFps(Math.max(0, Math.floor(fpsValue)));
+ }
+ return;
+ }
+
+ if (parsed.type === 'ERROR') {
+ setError(parsed.payload?.message ?? 'Unknown bridge error');
+ setIsReady(false);
+ }
+ }, []);
+
+ return {
+ sendFrame,
+ onMessage,
+ isReady,
+ fps,
+ error,
+ reset: () => {
+ setIsReady(false);
+ setFps(0);
+ setError(null);
+ },
+ };
+};
diff --git a/mobile/src/screens/MATScreen/AlertCard.tsx b/mobile/src/screens/MATScreen/AlertCard.tsx
index e69de29..01c586d 100644
--- a/mobile/src/screens/MATScreen/AlertCard.tsx
+++ b/mobile/src/screens/MATScreen/AlertCard.tsx
@@ -0,0 +1,84 @@
+import { View } from 'react-native';
+import { ThemedText } from '@/components/ThemedText';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+import { AlertPriority, type Alert } from '@/types/mat';
+
+type SeverityLevel = 'URGENT' | 'HIGH' | 'NORMAL';
+
+type AlertCardProps = {
+ alert: Alert;
+};
+
+type SeverityMeta = {
+ label: SeverityLevel;
+ icon: string;
+ color: string;
+};
+
+const resolveSeverity = (alert: Alert): SeverityMeta => {
+ if (alert.priority === AlertPriority.Critical) {
+ return {
+ label: 'URGENT',
+ icon: '‼',
+ color: colors.danger,
+ };
+ }
+
+ if (alert.priority === AlertPriority.High) {
+ return {
+ label: 'HIGH',
+ icon: '⚠',
+ color: colors.warn,
+ };
+ }
+
+ return {
+ label: 'NORMAL',
+ icon: '•',
+ color: colors.accent,
+ };
+};
+
+const formatTime = (value?: string): string => {
+ if (!value) {
+ return 'Unknown';
+ }
+
+ try {
+ return new Date(value).toLocaleTimeString();
+ } catch {
+ return 'Unknown';
+ }
+};
+
+export const AlertCard = ({ alert }: AlertCardProps) => {
+ const severity = resolveSeverity(alert);
+
+ return (
+
+
+
+ {severity.icon} {severity.label}
+
+
+
+ {formatTime(alert.created_at)}
+
+
+
+
+ {alert.message}
+
+
+ );
+};
diff --git a/mobile/src/screens/MATScreen/AlertList.tsx b/mobile/src/screens/MATScreen/AlertList.tsx
index e69de29..405d9df 100644
--- a/mobile/src/screens/MATScreen/AlertList.tsx
+++ b/mobile/src/screens/MATScreen/AlertList.tsx
@@ -0,0 +1,41 @@
+import { FlatList, View } from 'react-native';
+import { ThemedText } from '@/components/ThemedText';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+import type { Alert } from '@/types/mat';
+import { AlertCard } from './AlertCard';
+
+type AlertListProps = {
+ alerts: Alert[];
+};
+
+export const AlertList = ({ alerts }: AlertListProps) => {
+ if (alerts.length === 0) {
+ return (
+
+ No alerts — system nominal
+
+ );
+ }
+
+ return (
+ item.id}
+ renderItem={({ item }) => }
+ contentContainerStyle={{ paddingBottom: spacing.md }}
+ showsVerticalScrollIndicator={false}
+ removeClippedSubviews={false}
+ />
+ );
+};
diff --git a/mobile/src/screens/MATScreen/MatWebView.tsx b/mobile/src/screens/MATScreen/MatWebView.tsx
index e69de29..c9413dc 100644
--- a/mobile/src/screens/MATScreen/MatWebView.tsx
+++ b/mobile/src/screens/MATScreen/MatWebView.tsx
@@ -0,0 +1,26 @@
+import { StyleProp, ViewStyle } from 'react-native';
+import WebView, { type WebViewMessageEvent } from 'react-native-webview';
+import type { RefObject } from 'react';
+import MAT_DASHBOARD_HTML from '@/assets/webview/mat-dashboard.html';
+
+type MatWebViewProps = {
+ webViewRef: RefObject;
+ onMessage: (event: WebViewMessageEvent) => void;
+ style?: StyleProp;
+};
+
+export const MatWebView = ({ webViewRef, onMessage, style }: MatWebViewProps) => {
+ return (
+
+ );
+};
diff --git a/mobile/src/screens/MATScreen/SurvivorCounter.tsx b/mobile/src/screens/MATScreen/SurvivorCounter.tsx
index e69de29..ccedd28 100644
--- a/mobile/src/screens/MATScreen/SurvivorCounter.tsx
+++ b/mobile/src/screens/MATScreen/SurvivorCounter.tsx
@@ -0,0 +1,89 @@
+import { View } from 'react-native';
+import { ThemedText } from '@/components/ThemedText';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+import { TriageStatus, type Survivor } from '@/types/mat';
+
+type SurvivorCounterProps = {
+ survivors: Survivor[];
+};
+
+type Breakdown = {
+ immediate: number;
+ delayed: number;
+ minor: number;
+ deceased: number;
+ unknown: number;
+};
+
+const getBreakdown = (survivors: Survivor[]): Breakdown => {
+ const output = {
+ immediate: 0,
+ delayed: 0,
+ minor: 0,
+ deceased: 0,
+ unknown: 0,
+ };
+
+ survivors.forEach((survivor) => {
+ if (survivor.triage_status === TriageStatus.Immediate) {
+ output.immediate += 1;
+ return;
+ }
+ if (survivor.triage_status === TriageStatus.Delayed) {
+ output.delayed += 1;
+ return;
+ }
+ if (survivor.triage_status === TriageStatus.Minor) {
+ output.minor += 1;
+ return;
+ }
+ if (survivor.triage_status === TriageStatus.Deceased) {
+ output.deceased += 1;
+ return;
+ }
+
+ output.unknown += 1;
+ });
+
+ return output;
+};
+
+const BreakoutChip = ({ label, value, color }: { label: string; value: number; color: string }) => (
+
+
+ {label}: {value}
+
+
+);
+
+export const SurvivorCounter = ({ survivors }: SurvivorCounterProps) => {
+ const total = survivors.length;
+ const breakdown = getBreakdown(survivors);
+
+ return (
+
+
+ {total} SURVIVORS DETECTED
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/mobile/src/screens/MATScreen/index.tsx b/mobile/src/screens/MATScreen/index.tsx
index e69de29..6c4a1cd 100644
--- a/mobile/src/screens/MATScreen/index.tsx
+++ b/mobile/src/screens/MATScreen/index.tsx
@@ -0,0 +1,138 @@
+import { useEffect, useRef } from 'react';
+import { useWindowDimensions, View } from 'react-native';
+import { ConnectionBanner } from '@/components/ConnectionBanner';
+import { ThemedView } from '@/components/ThemedView';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+import { usePoseStream } from '@/hooks/usePoseStream';
+import { useMatStore } from '@/stores/matStore';
+import { type ConnectionStatus } from '@/types/sensing';
+import { Alert, type Survivor } from '@/types/mat';
+import { AlertList } from './AlertList';
+import { MatWebView } from './MatWebView';
+import { SurvivorCounter } from './SurvivorCounter';
+import { useMatBridge } from './useMatBridge';
+
+const isAlert = (value: unknown): value is Alert => {
+ if (!value || typeof value !== 'object') {
+ return false;
+ }
+
+ const record = value as Record;
+ return typeof record.id === 'string' && typeof record.message === 'string';
+};
+
+const isSurvivor = (value: unknown): value is Survivor => {
+ if (!value || typeof value !== 'object') {
+ return false;
+ }
+
+ const record = value as Record;
+ return typeof record.id === 'string' && typeof record.zone_id === 'string';
+};
+
+const resolveBannerState = (status: ConnectionStatus): 'connected' | 'simulated' | 'disconnected' => {
+ if (status === 'connecting') {
+ return 'disconnected';
+ }
+
+ return status;
+};
+
+export const MATScreen = () => {
+ const { connectionStatus, lastFrame } = usePoseStream();
+
+ const { survivors, alerts, upsertSurvivor, addAlert, upsertEvent } = useMatStore((state) => ({
+ survivors: state.survivors,
+ alerts: state.alerts,
+ upsertSurvivor: state.upsertSurvivor,
+ addAlert: state.addAlert,
+ upsertEvent: state.upsertEvent,
+ }));
+
+ const { webViewRef, ready, onMessage, sendFrameUpdate, postEvent } = useMatBridge({
+ onSurvivorDetected: (survivor) => {
+ if (isSurvivor(survivor)) {
+ upsertSurvivor(survivor);
+ }
+ },
+ onAlertGenerated: (alert) => {
+ if (isAlert(alert)) {
+ addAlert(alert);
+ }
+ },
+ });
+
+ const seededRef = useRef(false);
+
+ useEffect(() => {
+ if (!ready || seededRef.current) {
+ return;
+ }
+
+ const createEvent = postEvent('CREATE_EVENT');
+ createEvent({
+ type: 'earthquake',
+ latitude: 37.7749,
+ longitude: -122.4194,
+ name: 'Training Scenario',
+ });
+
+ const addZone = postEvent('ADD_ZONE');
+ addZone({
+ name: 'Zone A',
+ zone_type: 'rectangle',
+ x: 60,
+ y: 60,
+ width: 180,
+ height: 120,
+ });
+ addZone({
+ name: 'Zone B',
+ zone_type: 'circle',
+ center_x: 300,
+ center_y: 170,
+ radius: 60,
+ });
+
+ upsertEvent({
+ event_id: 'training-scenario',
+ disaster_type: 1,
+ latitude: 37.7749,
+ longitude: -122.4194,
+ description: 'Training Scenario',
+ });
+
+ seededRef.current = true;
+ }, [postEvent, upsertEvent, ready]);
+
+ useEffect(() => {
+ if (ready && lastFrame) {
+ sendFrameUpdate(lastFrame);
+ }
+ }, [lastFrame, ready, sendFrameUpdate]);
+
+ const { height } = useWindowDimensions();
+ const webHeight = Math.max(240, Math.floor(height * 0.5));
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default MATScreen;
diff --git a/mobile/src/screens/MATScreen/useMatBridge.ts b/mobile/src/screens/MATScreen/useMatBridge.ts
index e69de29..b502f42 100644
--- a/mobile/src/screens/MATScreen/useMatBridge.ts
+++ b/mobile/src/screens/MATScreen/useMatBridge.ts
@@ -0,0 +1,118 @@
+import { useCallback, useRef, useState } from 'react';
+import type { WebView, WebViewMessageEvent } from 'react-native-webview';
+import type { Alert, Survivor } from '@/types/mat';
+import type { SensingFrame } from '@/types/sensing';
+
+type MatBridgeMessageType = 'CREATE_EVENT' | 'ADD_ZONE' | 'FRAME_UPDATE';
+
+type MatIncomingType = 'READY' | 'SURVIVOR_DETECTED' | 'ALERT_GENERATED';
+
+type MatIncomingMessage = {
+ type: MatIncomingType;
+ payload?: unknown;
+};
+
+type MatOutgoingMessage = {
+ type: MatBridgeMessageType;
+ payload?: unknown;
+};
+
+type UseMatBridgeOptions = {
+ onSurvivorDetected?: (survivor: Survivor) => void;
+ onAlertGenerated?: (alert: Alert) => void;
+};
+
+const safeParseJson = (value: string): unknown | null => {
+ try {
+ return JSON.parse(value);
+ } catch {
+ return null;
+ }
+};
+
+export const useMatBridge = ({ onAlertGenerated, onSurvivorDetected }: UseMatBridgeOptions = {}) => {
+ const webViewRef = useRef(null);
+ const isReadyRef = useRef(false);
+ const queuedMessages = useRef([]);
+ const [ready, setReady] = useState(false);
+
+ const flush = useCallback(() => {
+ if (!webViewRef.current || !isReadyRef.current) {
+ return;
+ }
+
+ while (queuedMessages.current.length > 0) {
+ const payload = queuedMessages.current.shift();
+ if (payload) {
+ webViewRef.current.postMessage(payload);
+ }
+ }
+ }, []);
+
+ const sendMessage = useCallback(
+ (message: MatOutgoingMessage) => {
+ const payload = JSON.stringify(message);
+ if (isReadyRef.current && webViewRef.current) {
+ webViewRef.current.postMessage(payload);
+ return;
+ }
+ queuedMessages.current.push(payload);
+ },
+ [],
+ );
+
+ const sendFrameUpdate = useCallback(
+ (frame: SensingFrame) => {
+ sendMessage({ type: 'FRAME_UPDATE', payload: frame });
+ },
+ [sendMessage],
+ );
+
+ const postEvent = useCallback(
+ (type: 'CREATE_EVENT' | 'ADD_ZONE') => {
+ return (payload: unknown) => {
+ sendMessage({
+ type,
+ payload,
+ });
+ };
+ },
+ [sendMessage],
+ );
+
+ const onMessage = useCallback(
+ (event: WebViewMessageEvent) => {
+ const payload = safeParseJson(event.nativeEvent.data);
+ if (!payload || typeof payload !== 'object') {
+ return;
+ }
+
+ const message = payload as MatIncomingMessage;
+ if (message.type === 'READY') {
+ isReadyRef.current = true;
+ setReady(true);
+ flush();
+ return;
+ }
+
+ if (message.type === 'SURVIVOR_DETECTED') {
+ onSurvivorDetected?.(message.payload as Survivor);
+ return;
+ }
+
+ if (message.type === 'ALERT_GENERATED') {
+ onAlertGenerated?.(message.payload as Alert);
+ }
+ },
+ [flush, onAlertGenerated, onSurvivorDetected],
+ );
+
+ return {
+ webViewRef,
+ ready,
+ onMessage,
+ sendMessage,
+ sendFrameUpdate,
+ postEvent,
+ };
+};
diff --git a/mobile/src/screens/SettingsScreen/RssiToggle.tsx b/mobile/src/screens/SettingsScreen/RssiToggle.tsx
index e69de29..c2f68a8 100644
--- a/mobile/src/screens/SettingsScreen/RssiToggle.tsx
+++ b/mobile/src/screens/SettingsScreen/RssiToggle.tsx
@@ -0,0 +1,36 @@
+import { Platform, Switch, View } from 'react-native';
+import { ThemedText } from '@/components/ThemedText';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+
+type RssiToggleProps = {
+ enabled: boolean;
+ onChange: (value: boolean) => void;
+};
+
+export const RssiToggle = ({ enabled, onChange }: RssiToggleProps) => {
+ return (
+
+
+
+ RSSI Scan
+
+ Scan for nearby Wi-Fi signals from Android devices
+
+
+
+
+
+ {Platform.OS === 'ios' && (
+
+ iOS: RSSI scan is currently limited — using stub data.
+
+ )}
+
+ );
+};
diff --git a/mobile/src/screens/SettingsScreen/ServerUrlInput.tsx b/mobile/src/screens/SettingsScreen/ServerUrlInput.tsx
index e69de29..79a4cc9 100644
--- a/mobile/src/screens/SettingsScreen/ServerUrlInput.tsx
+++ b/mobile/src/screens/SettingsScreen/ServerUrlInput.tsx
@@ -0,0 +1,102 @@
+import { useState } from 'react';
+import { Pressable, TextInput, View } from 'react-native';
+import { validateServerUrl } from '@/utils/urlValidator';
+import { apiService } from '@/services/api.service';
+import { ThemedText } from '@/components/ThemedText';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+
+type ServerUrlInputProps = {
+ value: string;
+ onChange: (value: string) => void;
+ onSave: () => void;
+};
+
+export const ServerUrlInput = ({ value, onChange, onSave }: ServerUrlInputProps) => {
+ const [testResult, setTestResult] = useState('');
+
+ const validation = validateServerUrl(value);
+
+ const handleTest = async () => {
+ if (!validation.valid) {
+ setTestResult('✗ Invalid URL');
+ return;
+ }
+
+ const start = Date.now();
+ try {
+ await apiService.getStatus();
+ setTestResult(`✓ ${Date.now() - start}ms`);
+ } catch {
+ setTestResult('✗ Failed');
+ }
+ };
+
+ return (
+
+
+ Server URL
+
+
+ {!validation.valid && (
+
+ {validation.error}
+
+ )}
+
+
+ {testResult || 'Ready to test connection'}
+
+
+
+
+
+ Test Connection
+
+
+
+
+ Save
+
+
+
+
+ );
+};
diff --git a/mobile/src/screens/SettingsScreen/ThemePicker.tsx b/mobile/src/screens/SettingsScreen/ThemePicker.tsx
index e69de29..bc0ae15 100644
--- a/mobile/src/screens/SettingsScreen/ThemePicker.tsx
+++ b/mobile/src/screens/SettingsScreen/ThemePicker.tsx
@@ -0,0 +1,47 @@
+import { Pressable, View } from 'react-native';
+import { ThemeMode } from '@/theme/ThemeContext';
+import { ThemedText } from '@/components/ThemedText';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+
+type ThemePickerProps = {
+ value: ThemeMode;
+ onChange: (value: ThemeMode) => void;
+};
+
+const OPTIONS: ThemeMode[] = ['light', 'dark', 'system'];
+
+export const ThemePicker = ({ value, onChange }: ThemePickerProps) => {
+ return (
+
+ {OPTIONS.map((option) => {
+ const isActive = option === value;
+ return (
+ onChange(option)}
+ style={{
+ flex: 1,
+ borderRadius: 8,
+ borderWidth: 1,
+ borderColor: isActive ? colors.accent : colors.border,
+ backgroundColor: isActive ? `${colors.accent}22` : '#0D1117',
+ paddingVertical: 10,
+ alignItems: 'center',
+ }}
+ >
+
+ {option.toUpperCase()}
+
+
+ );
+ })}
+
+ );
+};
diff --git a/mobile/src/screens/SettingsScreen/index.tsx b/mobile/src/screens/SettingsScreen/index.tsx
index e69de29..2bfc475 100644
--- a/mobile/src/screens/SettingsScreen/index.tsx
+++ b/mobile/src/screens/SettingsScreen/index.tsx
@@ -0,0 +1,169 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Linking, ScrollView, View } from 'react-native';
+import { ThemedText } from '@/components/ThemedText';
+import { ThemedView } from '@/components/ThemedView';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+import { WS_PATH } from '@/constants/websocket';
+import { apiService } from '@/services/api.service';
+import { wsService } from '@/services/ws.service';
+import { useSettingsStore } from '@/stores/settingsStore';
+import { Alert, Pressable, Platform } from 'react-native';
+import { ThemePicker } from './ThemePicker';
+import { RssiToggle } from './RssiToggle';
+import { ServerUrlInput } from './ServerUrlInput';
+
+type GlowCardProps = {
+ title: string;
+ children: React.ReactNode;
+};
+
+const GlowCard = ({ title, children }: GlowCardProps) => {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+};
+
+const ScanIntervalPicker = ({
+ value,
+ onChange,
+}: {
+ value: number;
+ onChange: (value: number) => void;
+}) => {
+ const options = [1, 2, 5];
+
+ return (
+
+ {options.map((interval) => {
+ const isActive = interval === value;
+ return (
+ onChange(interval)}
+ style={{
+ flex: 1,
+ borderWidth: 1,
+ borderColor: isActive ? colors.accent : colors.border,
+ borderRadius: 8,
+ backgroundColor: isActive ? `${colors.accent}20` : colors.surface,
+ alignItems: 'center',
+ }}
+ >
+
+ {interval}s
+
+
+ );
+ })}
+
+ );
+};
+
+export const SettingsScreen = () => {
+ const serverUrl = useSettingsStore((state) => state.serverUrl);
+ const rssiScanEnabled = useSettingsStore((state) => state.rssiScanEnabled);
+ const theme = useSettingsStore((state) => state.theme);
+ const setServerUrl = useSettingsStore((state) => state.setServerUrl);
+ const setRssiScanEnabled = useSettingsStore((state) => state.setRssiScanEnabled);
+ const setTheme = useSettingsStore((state) => state.setTheme);
+
+ const [draftUrl, setDraftUrl] = useState(serverUrl);
+ const [scanInterval, setScanInterval] = useState(2);
+
+ useEffect(() => {
+ setDraftUrl(serverUrl);
+ }, [serverUrl]);
+
+ const intervalSummary = useMemo(() => `${scanInterval}s`, [scanInterval]);
+
+ const handleSaveUrl = () => {
+ setServerUrl(draftUrl);
+ apiService.setBaseUrl(draftUrl);
+ wsService.disconnect();
+ wsService.connect(draftUrl);
+ };
+
+ const handleOpenGitHub = async () => {
+ const handled = await Linking.canOpenURL('https://github.com');
+ if (!handled) {
+ Alert.alert('Unable to open link', 'Please open https://github.com manually in your browser.');
+ return;
+ }
+
+ await Linking.openURL('https://github.com');
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ Scan interval
+
+
+
+ Active interval: {intervalSummary}
+
+ {Platform.OS === 'ios' && (
+
+ iOS: RSSI scanning uses stubbed telemetry in this build.
+
+ )}
+
+
+
+
+
+
+
+
+ WiFi-DensePose Mobile v1.0.0
+
+
+ View on GitHub
+
+ WebSocket: {WS_PATH}
+
+ Triage priority mapping: Immediate/Delayed/Minor/Deceased/Unknown
+
+
+
+
+ );
+};
+
+export default SettingsScreen;
diff --git a/mobile/src/screens/VitalsScreen/BreathingGauge.tsx b/mobile/src/screens/VitalsScreen/BreathingGauge.tsx
index e69de29..5795a76 100644
--- a/mobile/src/screens/VitalsScreen/BreathingGauge.tsx
+++ b/mobile/src/screens/VitalsScreen/BreathingGauge.tsx
@@ -0,0 +1,63 @@
+import { useMemo } from 'react';
+import { View, StyleSheet } from 'react-native';
+import { usePoseStore } from '@/stores/poseStore';
+import { GaugeArc } from '@/components/GaugeArc';
+import { colors } from '@/theme/colors';
+import { ThemedText } from '@/components/ThemedText';
+
+const BREATHING_MIN_BPM = 0;
+const BREATHING_MAX_BPM = 30;
+const BREATHING_BAND_MAX = 0.3;
+
+const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
+
+const deriveBreathingValue = (
+ breathingBand?: number,
+ breathingBpm?: number,
+): number => {
+ if (typeof breathingBpm === 'number' && Number.isFinite(breathingBpm)) {
+ return clamp(breathingBpm, BREATHING_MIN_BPM, BREATHING_MAX_BPM);
+ }
+
+ const bandValue = typeof breathingBand === 'number' && Number.isFinite(breathingBand) ? breathingBand : 0;
+ const normalized = clamp(bandValue / BREATHING_BAND_MAX, 0, 1);
+ return normalized * BREATHING_MAX_BPM;
+};
+
+export const BreathingGauge = () => {
+ const breathingBand = usePoseStore((state) => state.features?.breathing_band_power);
+ const breathingBpm = usePoseStore((state) => state.lastFrame?.vital_signs?.breathing_bpm);
+
+ const value = useMemo(
+ () => deriveBreathingValue(breathingBand, breathingBpm),
+ [breathingBand, breathingBpm],
+ );
+
+ return (
+
+
+ BREATHING
+
+
+
+ BPM
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 6,
+ },
+ label: {
+ color: '#94A3B8',
+ letterSpacing: 1,
+ },
+ unit: {
+ marginTop: -12,
+ marginBottom: 4,
+ },
+});
diff --git a/mobile/src/screens/VitalsScreen/HeartRateGauge.tsx b/mobile/src/screens/VitalsScreen/HeartRateGauge.tsx
index e69de29..c106066 100644
--- a/mobile/src/screens/VitalsScreen/HeartRateGauge.tsx
+++ b/mobile/src/screens/VitalsScreen/HeartRateGauge.tsx
@@ -0,0 +1,76 @@
+import { useMemo } from 'react';
+import { StyleSheet, View } from 'react-native';
+import { usePoseStore } from '@/stores/poseStore';
+import { GaugeArc } from '@/components/GaugeArc';
+import { colors } from '@/theme/colors';
+import { ThemedText } from '@/components/ThemedText';
+
+const HEART_MIN_BPM = 40;
+const HEART_MAX_BPM = 120;
+const MOTION_BAND_MAX = 0.5;
+const BREATH_BAND_MAX = 0.3;
+
+const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
+
+const deriveHeartRate = (
+ heartbeat?: number,
+ motionBand?: number,
+ breathingBand?: number,
+): number => {
+ if (typeof heartbeat === 'number' && Number.isFinite(heartbeat)) {
+ return clamp(heartbeat, HEART_MIN_BPM, HEART_MAX_BPM);
+ }
+
+ const motionValue = typeof motionBand === 'number' && Number.isFinite(motionBand) ? clamp(motionBand / MOTION_BAND_MAX, 0, 1) : 0;
+ const breathValue = typeof breathingBand === 'number' && Number.isFinite(breathingBand) ? clamp(breathingBand / BREATH_BAND_MAX, 0, 1) : 0;
+
+ const normalized = 0.7 * motionValue + 0.3 * breathValue;
+ return HEART_MIN_BPM + normalized * (HEART_MAX_BPM - HEART_MIN_BPM);
+};
+
+export const HeartRateGauge = () => {
+ const heartProxyBpm = usePoseStore((state) => state.lastFrame?.vital_signs?.hr_proxy_bpm);
+ const motionBand = usePoseStore((state) => state.features?.motion_band_power);
+ const breathingBand = usePoseStore((state) => state.features?.breathing_band_power);
+
+ const value = useMemo(
+ () => deriveHeartRate(heartProxyBpm, motionBand, breathingBand),
+ [heartProxyBpm, motionBand, breathingBand],
+ );
+
+ return (
+
+
+ HR PROXY
+
+
+
+ (estimated)
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 6,
+ },
+ label: {
+ color: '#94A3B8',
+ letterSpacing: 1,
+ },
+ note: {
+ marginTop: -12,
+ marginBottom: 4,
+ },
+});
diff --git a/mobile/src/screens/VitalsScreen/MetricCard.tsx b/mobile/src/screens/VitalsScreen/MetricCard.tsx
index e69de29..fb4bc8d 100644
--- a/mobile/src/screens/VitalsScreen/MetricCard.tsx
+++ b/mobile/src/screens/VitalsScreen/MetricCard.tsx
@@ -0,0 +1,111 @@
+import { useEffect, useMemo, useState } from 'react';
+import { StyleSheet, View } from 'react-native';
+import {
+ runOnJS,
+ useAnimatedReaction,
+ useSharedValue,
+ withSpring,
+} from 'react-native-reanimated';
+import { SparklineChart } from '@/components/SparklineChart';
+import { ThemedText } from '@/components/ThemedText';
+import { colors } from '@/theme/colors';
+
+type MetricCardProps = {
+ label: string;
+ value: number | string;
+ unit?: string;
+ color?: string;
+ sparklineData?: number[];
+};
+
+const formatMetricValue = (value: number, unit?: string) => {
+ if (!Number.isFinite(value)) {
+ return '--';
+ }
+ const decimals = Math.abs(value) >= 100 ? 0 : Math.abs(value) >= 10 ? 2 : 3;
+ const text = value.toFixed(decimals);
+ return unit ? `${text} ${unit}` : text;
+};
+
+export const MetricCard = ({ label, value, unit, color = colors.accent, sparklineData }: MetricCardProps) => {
+ const numericValue = typeof value === 'number' ? value : null;
+ const [displayValue, setDisplayValue] = useState(() =>
+ numericValue !== null ? formatMetricValue(numericValue, unit) : String(value ?? '--'),
+ );
+
+ const valueAnimation = useSharedValue(numericValue ?? 0);
+
+ const finalValue = useMemo(
+ () => (numericValue !== null ? numericValue : NaN),
+ [numericValue],
+ );
+
+ useEffect(() => {
+ if (numericValue === null) {
+ setDisplayValue(String(value ?? '--'));
+ return;
+ }
+
+ valueAnimation.value = withSpring(finalValue, {
+ damping: 18,
+ stiffness: 160,
+ mass: 1,
+ });
+ }, [finalValue, numericValue, value, valueAnimation]);
+
+ useAnimatedReaction(
+ () => valueAnimation.value,
+ (current) => {
+ runOnJS(setDisplayValue)(formatMetricValue(current, unit));
+ },
+ [unit],
+ );
+
+ return (
+
+
+ {label}
+
+
+ {displayValue}
+
+ {sparklineData && sparklineData.length > 0 && (
+
+
+
+ )}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ card: {
+ backgroundColor: colors.surface,
+ borderWidth: 1,
+ borderRadius: 14,
+ padding: 12,
+ marginBottom: 10,
+ gap: 6,
+ shadowOffset: {
+ width: 0,
+ height: 0,
+ },
+ shadowRadius: 12,
+ elevation: 4,
+ },
+ label: {
+ color: colors.textSecondary,
+ textTransform: 'uppercase',
+ letterSpacing: 0.8,
+ },
+ value: {
+ color: colors.textPrimary,
+ marginBottom: 2,
+ },
+ sparklineWrap: {
+ marginTop: 4,
+ borderTopWidth: 1,
+ borderTopColor: colors.border,
+ paddingTop: 8,
+ },
+});
diff --git a/mobile/src/screens/VitalsScreen/index.tsx b/mobile/src/screens/VitalsScreen/index.tsx
index e69de29..705022a 100644
--- a/mobile/src/screens/VitalsScreen/index.tsx
+++ b/mobile/src/screens/VitalsScreen/index.tsx
@@ -0,0 +1,206 @@
+import { useEffect } from 'react';
+import { ScrollView, StyleSheet, View } from 'react-native';
+import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
+import { BreathingGauge } from './BreathingGauge';
+import { HeartRateGauge } from './HeartRateGauge';
+import { MetricCard } from './MetricCard';
+import { ConnectionBanner } from '@/components/ConnectionBanner';
+import { ModeBadge } from '@/components/ModeBadge';
+import { ThemedText } from '@/components/ThemedText';
+import { ThemedView } from '@/components/ThemedView';
+import { SparklineChart } from '@/components/SparklineChart';
+import { usePoseStore } from '@/stores/poseStore';
+import { usePoseStream } from '@/hooks/usePoseStream';
+import { colors } from '@/theme/colors';
+
+type ConnectionBannerState = 'connected' | 'simulated' | 'disconnected';
+
+const clampPercent = (value: number) => {
+ const normalized = Number.isFinite(value) ? value : 0;
+ return Math.max(0, Math.min(1, normalized > 1 ? normalized / 100 : normalized));
+};
+
+export default function VitalsScreen() {
+ usePoseStream();
+
+ const connectionStatus = usePoseStore((state) => state.connectionStatus);
+ const isSimulated = usePoseStore((state) => state.isSimulated);
+ const features = usePoseStore((state) => state.features);
+ const classification = usePoseStore((state) => state.classification);
+ const rssiHistory = usePoseStore((state) => state.rssiHistory);
+
+ const confidence = clampPercent(classification?.confidence ?? 0);
+ const badgeLabel = (classification?.motion_level ?? 'ABSENT').toUpperCase();
+
+ const bannerStatus: ConnectionBannerState = connectionStatus === 'connected' ? 'connected' : connectionStatus === 'simulated' ? 'simulated' : 'disconnected';
+
+ const confidenceProgress = useSharedValue(0);
+
+ useEffect(() => {
+ confidenceProgress.value = withSpring(confidence, {
+ damping: 16,
+ stiffness: 150,
+ mass: 1,
+ });
+ }, [confidence, confidenceProgress]);
+
+ const animatedConfidenceStyle = useAnimatedStyle(() => ({
+ width: `${confidenceProgress.value * 100}%`,
+ }));
+
+ const classificationColor =
+ classification?.motion_level === 'active'
+ ? colors.success
+ : classification?.motion_level === 'present_still'
+ ? colors.warn
+ : colors.muted;
+
+ return (
+
+
+
+
+ {isSimulated ? : null}
+
+
+
+
+
+
+
+
+
+
+
+
+ RSSI HISTORY
+
+ 0 ? rssiHistory : [0]} color={colors.accent} />
+
+
+
+
+
+
+
+
+
+ Classification: {badgeLabel}
+
+
+
+ {badgeLabel}
+
+
+
+
+ Confidence
+
+
+
+
+ {Math.round(confidence * 100)}%
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ screen: {
+ flex: 1,
+ backgroundColor: colors.bg,
+ paddingTop: 40,
+ paddingHorizontal: 12,
+ },
+ content: {
+ paddingTop: 12,
+ paddingBottom: 30,
+ gap: 12,
+ },
+ headerRow: {
+ alignItems: 'flex-end',
+ },
+ gaugesRow: {
+ flexDirection: 'row',
+ gap: 12,
+ },
+ gaugeCard: {
+ flex: 1,
+ backgroundColor: '#111827',
+ borderRadius: 16,
+ borderWidth: 1,
+ borderColor: 'rgba(50,184,198,0.45)',
+ paddingVertical: 10,
+ paddingHorizontal: 8,
+ alignItems: 'center',
+ justifyContent: 'center',
+ shadowColor: colors.accent,
+ shadowOpacity: 0.3,
+ shadowOffset: {
+ width: 0,
+ height: 0,
+ },
+ shadowRadius: 12,
+ elevation: 4,
+ },
+ section: {
+ backgroundColor: colors.surface,
+ borderRadius: 14,
+ borderWidth: 1,
+ borderColor: 'rgba(50,184,198,0.35)',
+ padding: 12,
+ gap: 10,
+ },
+ classificationSection: {
+ backgroundColor: colors.surface,
+ borderRadius: 14,
+ borderWidth: 1,
+ borderColor: 'rgba(50,184,198,0.35)',
+ padding: 12,
+ gap: 10,
+ marginBottom: 6,
+ },
+ rowLabel: {
+ color: colors.textSecondary,
+ marginBottom: 8,
+ },
+ badgePill: {
+ alignSelf: 'flex-start',
+ borderWidth: 1,
+ borderRadius: 999,
+ paddingHorizontal: 10,
+ paddingVertical: 4,
+ marginBottom: 4,
+ },
+ confidenceContainer: {
+ gap: 6,
+ },
+ confidenceBarTrack: {
+ height: 10,
+ borderRadius: 999,
+ backgroundColor: colors.surfaceAlt,
+ overflow: 'hidden',
+ },
+ confidenceBarFill: {
+ height: '100%',
+ backgroundColor: colors.success,
+ borderRadius: 999,
+ },
+});
diff --git a/mobile/src/screens/ZonesScreen/FloorPlanSvg.tsx b/mobile/src/screens/ZonesScreen/FloorPlanSvg.tsx
index e69de29..43a9728 100644
--- a/mobile/src/screens/ZonesScreen/FloorPlanSvg.tsx
+++ b/mobile/src/screens/ZonesScreen/FloorPlanSvg.tsx
@@ -0,0 +1,201 @@
+import { useEffect, useMemo } from 'react';
+import { View, ViewStyle } from 'react-native';
+import Svg, { Circle, Polygon, Rect } from 'react-native-svg';
+import Animated, {
+ createAnimatedComponent,
+ useAnimatedProps,
+ useAnimatedStyle,
+ useDerivedValue,
+ useSharedValue,
+ withTiming,
+ type SharedValue,
+} from 'react-native-reanimated';
+import {
+ Gesture,
+ GestureDetector,
+} from 'react-native-gesture-handler';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+import { valueToColor } from '@/utils/colorMap';
+
+const GRID_SIZE = 20;
+const CELL_COUNT = GRID_SIZE * GRID_SIZE;
+
+type Point = {
+ x: number;
+ y: number;
+};
+
+type FloorPlanSvgProps = {
+ gridValues: number[];
+ personPositions: Point[];
+ size?: number;
+ style?: ViewStyle;
+};
+
+const clamp01 = (value: number) => Math.max(0, Math.min(1, value));
+
+const colorToRgba = (value: number): string => {
+ const [r, g, b] = valueToColor(clamp01(value));
+ return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 1)`;
+};
+
+const normalizeGrid = (values: number[]): number[] => {
+ const normalized = new Array(CELL_COUNT).fill(0);
+ const sourceLength = Math.min(values.length, CELL_COUNT);
+
+ for (let i = 0; i < sourceLength; i += 1) {
+ const raw = values?.[i];
+ normalized[i] = clamp01(typeof raw === 'number' && Number.isFinite(raw) ? raw : 0);
+ }
+
+ return normalized;
+};
+
+const AnimatedRect = createAnimatedComponent(Rect);
+
+const AnimatedContainer = Animated.View;
+
+const Cell = ({
+ index,
+ size,
+ values,
+ progress,
+}: {
+ index: number;
+ size: number;
+ values: SharedValue;
+ progress: SharedValue;
+}) => {
+ const cellSize = size / GRID_SIZE;
+ const x = (index % GRID_SIZE) * cellSize;
+ const y = Math.floor(index / GRID_SIZE) * cellSize;
+
+ const animatedProps = useAnimatedProps(() => {
+ const fill = colorToRgba(values.value[index] ?? 0);
+ return {
+ fill,
+ opacity: 0.95 + (progress.value - 1) * 0.05,
+ };
+ }, [index]);
+
+ return ;
+};
+
+const RouterMarker = ({ cellSize }: { cellSize: number }) => {
+ const cx = cellSize * 5.5;
+ const cy = cellSize * 17.5;
+ const radius = cellSize * 0.35;
+
+ return (
+
+ );
+};
+
+export const FloorPlanSvg = ({ gridValues, personPositions, size = 320, style }: FloorPlanSvgProps) => {
+ const normalizedValues = useMemo(() => normalizeGrid(gridValues), [gridValues]);
+
+ const values = useSharedValue(normalizedValues);
+ const previousValues = useSharedValue(normalizedValues);
+ const targetValues = useSharedValue(normalizedValues);
+ const progress = useSharedValue(1);
+
+ const translateX = useSharedValue(0);
+ const translateY = useSharedValue(0);
+ const panStartX = useSharedValue(0);
+ const panStartY = useSharedValue(0);
+
+ const panGesture = Gesture.Pan()
+ .onStart(() => {
+ panStartX.value = translateX.value;
+ panStartY.value = translateY.value;
+ })
+ .onUpdate((event) => {
+ translateX.value = panStartX.value + event.translationX;
+ translateY.value = panStartY.value + event.translationY;
+ })
+ .onEnd(() => {
+ panStartX.value = translateX.value;
+ panStartY.value = translateY.value;
+ });
+
+ const panStyle = useAnimatedStyle(() => ({
+ transform: [
+ { translateX: translateX.value },
+ { translateY: translateY.value },
+ ],
+ }));
+
+ useDerivedValue(() => {
+ const interpolated = new Array(CELL_COUNT).fill(0);
+ const from = previousValues.value;
+ const to = targetValues.value;
+ const p = progress.value;
+
+ for (let i = 0; i < CELL_COUNT; i += 1) {
+ const start = from[i] ?? 0;
+ const end = to[i] ?? 0;
+ interpolated[i] = start + (end - start) * p;
+ }
+ values.value = interpolated;
+ });
+
+ useEffect(() => {
+ const next = normalizeGrid(normalizedValues);
+ previousValues.value = values.value;
+ targetValues.value = next;
+ progress.value = 0;
+ progress.value = withTiming(1, { duration: 500 });
+ }, [normalizedValues, previousValues, targetValues, progress, values]);
+
+ const markers = useMemo(() => {
+ const cellSize = size / GRID_SIZE;
+ return personPositions
+ .map((point, idx) => {
+ const cx = (Math.max(0, Math.min(GRID_SIZE - 1, point.x)) + 0.5) * cellSize;
+ const cy = (Math.max(0, Math.min(GRID_SIZE - 1, point.y)) + 0.5) * cellSize;
+ const radius = Math.max(2.8, cellSize * 0.22);
+
+ return (
+
+ );
+ })
+ .concat(
+ ,
+ );
+ }, [personPositions, size]);
+
+ return (
+
+
+
+
+
+
+
+ );
+};
diff --git a/mobile/src/screens/ZonesScreen/ZoneLegend.tsx b/mobile/src/screens/ZonesScreen/ZoneLegend.tsx
index e69de29..deceede 100644
--- a/mobile/src/screens/ZonesScreen/ZoneLegend.tsx
+++ b/mobile/src/screens/ZonesScreen/ZoneLegend.tsx
@@ -0,0 +1,54 @@
+import { View } from 'react-native';
+import { ThemedText } from '@/components/ThemedText';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+import { valueToColor } from '@/utils/colorMap';
+
+type LegendStop = {
+ label: string;
+ color: string;
+};
+
+const LEGEND_STOPS: LegendStop[] = [
+ { label: 'Quiet', color: colorToRgba(0) },
+ { label: 'Low', color: colorToRgba(0.25) },
+ { label: 'Medium', color: colorToRgba(0.5) },
+ { label: 'High', color: colorToRgba(0.75) },
+ { label: 'Active', color: colorToRgba(1) },
+];
+
+function colorToRgba(value: number): string {
+ const [r, g, b] = valueToColor(value);
+ return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 1)`;
+}
+
+export const ZoneLegend = () => {
+ return (
+
+ {LEGEND_STOPS.map((stop) => (
+
+
+
+ {stop.label}
+
+
+ ))}
+
+ );
+};
diff --git a/mobile/src/screens/ZonesScreen/index.tsx b/mobile/src/screens/ZonesScreen/index.tsx
index e69de29..9565d1e 100644
--- a/mobile/src/screens/ZonesScreen/index.tsx
+++ b/mobile/src/screens/ZonesScreen/index.tsx
@@ -0,0 +1,82 @@
+import { useMemo } from 'react';
+import { ScrollView, useWindowDimensions, View } from 'react-native';
+import { ConnectionBanner } from '@/components/ConnectionBanner';
+import { ThemedText } from '@/components/ThemedText';
+import { ThemedView } from '@/components/ThemedView';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+import { usePoseStore } from '@/stores/poseStore';
+import { type ConnectionStatus } from '@/types/sensing';
+import { useOccupancyGrid } from './useOccupancyGrid';
+import { FloorPlanSvg } from './FloorPlanSvg';
+import { ZoneLegend } from './ZoneLegend';
+
+const getLastUpdateSeconds = (timestamp?: number): string => {
+ if (!timestamp) {
+ return 'N/A';
+ }
+
+ const ageMs = Date.now() - timestamp;
+ const secs = Math.max(0, ageMs / 1000);
+ return `${secs.toFixed(1)}s`;
+};
+
+const resolveBannerState = (status: ConnectionStatus): 'connected' | 'simulated' | 'disconnected' => {
+ if (status === 'connecting') {
+ return 'disconnected';
+ }
+
+ return status;
+};
+
+export const ZonesScreen = () => {
+ const connectionStatus = usePoseStore((state) => state.connectionStatus);
+ const lastFrame = usePoseStore((state) => state.lastFrame);
+ const signalField = usePoseStore((state) => state.signalField);
+
+ const { gridValues, personPositions } = useOccupancyGrid(signalField);
+
+ const { width } = useWindowDimensions();
+ const mapSize = useMemo(() => Math.max(240, Math.min(width - spacing.md * 2, 520)), [width]);
+
+ return (
+
+
+
+
+
+ Floor Plan — Occupancy Heatmap
+
+
+
+
+
+
+
+
+ Occupancy: {personPositions.length} persons detected
+ Last update: {getLastUpdateSeconds(lastFrame?.timestamp)}
+
+
+
+ );
+};
+
+export default ZonesScreen;
diff --git a/mobile/src/screens/ZonesScreen/useOccupancyGrid.ts b/mobile/src/screens/ZonesScreen/useOccupancyGrid.ts
index e69de29..4601bdd 100644
--- a/mobile/src/screens/ZonesScreen/useOccupancyGrid.ts
+++ b/mobile/src/screens/ZonesScreen/useOccupancyGrid.ts
@@ -0,0 +1,109 @@
+import { useMemo } from 'react';
+import type { Classification, SignalField } from '@/types/sensing';
+import { usePoseStore } from '@/stores/poseStore';
+
+const GRID_SIZE = 20;
+const CELL_COUNT = GRID_SIZE * GRID_SIZE;
+
+type Point = {
+ x: number;
+ y: number;
+};
+
+const clamp01 = (value: number): number => {
+ if (Number.isNaN(value)) {
+ return 0;
+ }
+
+ return Math.max(0, Math.min(1, value));
+};
+
+const parseNumber = (value: unknown): number | null => {
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
+};
+
+const parsePoint = (value: unknown): Point | null => {
+ if (!value || typeof value !== 'object') {
+ return null;
+ }
+
+ const record = value as Record;
+ const x = parseNumber(record.x);
+ const y = parseNumber(record.y);
+
+ if (x === null || y === null) {
+ return null;
+ }
+
+ return {
+ x,
+ y,
+ };
+};
+
+const collectPositions = (value: unknown): Point[] => {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+
+ return value
+ .map((entry) => parsePoint(entry))
+ .filter((point): point is Point => point !== null)
+ .map((point) => ({
+ x: point.x,
+ y: point.y,
+ }));
+};
+
+const readClassificationPositions = (classification: Classification | undefined): Point[] => {
+ const source = classification as unknown as Record;
+
+ return (
+ collectPositions(source?.persons) ??
+ collectPositions(source?.personPositions) ??
+ collectPositions(source?.positions) ??
+ []
+ );
+};
+
+export const useOccupancyGrid = (signalField: SignalField | null): { gridValues: number[]; personPositions: Point[] } => {
+ const classification = usePoseStore((state) => state.classification) as Classification | undefined;
+
+ const gridValues = useMemo(() => {
+ const sourceValues = signalField?.values;
+
+ if (!sourceValues || sourceValues.length === 0) {
+ return new Array(CELL_COUNT).fill(0);
+ }
+
+ const normalized = new Array(CELL_COUNT).fill(0);
+ const sourceLength = Math.min(CELL_COUNT, sourceValues.length);
+
+ for (let i = 0; i < sourceLength; i += 1) {
+ const value = parseNumber(sourceValues[i]);
+ normalized[i] = clamp01(value ?? 0);
+ }
+
+ return normalized;
+ }, [signalField?.values]);
+
+ const personPositions = useMemo(() => {
+ const positions = readClassificationPositions(classification);
+
+ if (positions.length > 0) {
+ return positions
+ .map(({ x, y }) => ({
+ x: Math.max(0, Math.min(GRID_SIZE - 1, x)),
+ y: Math.max(0, Math.min(GRID_SIZE - 1, y)),
+ }))
+ .slice(0, 16);
+ }
+
+ return [] as Point[];
+ }, [classification]);
+
+ return {
+ gridValues,
+ personPositions,
+ };
+};
diff --git a/mobile/src/types/html.d.ts b/mobile/src/types/html.d.ts
new file mode 100644
index 0000000..448f7d1
--- /dev/null
+++ b/mobile/src/types/html.d.ts
@@ -0,0 +1,4 @@
+declare module '*.html' {
+ const content: string;
+ export default content;
+}