feat: Phase 4 — Live, Vitals, Zones, MAT, Settings screens
LiveScreen: GaussianSplatWebView + gaussian-splats.html (Three.js 3D viz), LiveHUD VitalsScreen: BreathingGauge, HeartRateGauge, MetricCard (Reanimated arcs) ZonesScreen: FloorPlanSvg (SVG heatmap 20x20), ZoneLegend, useOccupancyGrid MATScreen: MatWebView + mat-dashboard.html (pure-JS disaster response), AlertCard/List, SurvivorCounter SettingsScreen: ServerUrlInput (URL validation + test), ThemePicker, RssiToggle Verified: tsc 0 errors, jest passes
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { LayoutChangeEvent, StyleSheet } from 'react-native';
|
||||
import type { RefObject } from 'react';
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||
import GAUSSIAN_SPLATS_HTML from '@/assets/webview/gaussian-splats.html';
|
||||
|
||||
type GaussianSplatWebViewProps = {
|
||||
onMessage: (event: WebViewMessageEvent) => void;
|
||||
onError: () => void;
|
||||
webViewRef: RefObject<WebView | null>;
|
||||
onLayout?: (event: LayoutChangeEvent) => void;
|
||||
};
|
||||
|
||||
export const GaussianSplatWebView = ({
|
||||
onMessage,
|
||||
onError,
|
||||
webViewRef,
|
||||
onLayout,
|
||||
}: GaussianSplatWebViewProps) => {
|
||||
const html = typeof GAUSSIAN_SPLATS_HTML === 'string' ? GAUSSIAN_SPLATS_HTML : '';
|
||||
|
||||
return (
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={{ html }}
|
||||
originWhitelist={['*']}
|
||||
allowFileAccess={false}
|
||||
javaScriptEnabled
|
||||
onMessage={onMessage}
|
||||
onError={onError}
|
||||
onLayout={onLayout}
|
||||
style={styles.webView}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
webView: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0A0E1A',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<ConnectionStatus, string> = {
|
||||
connected: 'Connected',
|
||||
simulated: 'Simulated',
|
||||
connecting: 'Connecting',
|
||||
disconnected: 'Disconnected',
|
||||
};
|
||||
|
||||
const statusDotStatusMap: Record<ConnectionStatus, 'connected' | 'simulated' | 'disconnected' | 'connecting'> = {
|
||||
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 (
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={togglePanel}>
|
||||
<Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, animatedPanelStyle]}>
|
||||
{/* App title */}
|
||||
<View style={styles.topLeft}>
|
||||
<ThemedText preset="labelLg" style={styles.appTitle}>
|
||||
WiFi-DensePose
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
{/* Status + FPS */}
|
||||
<View style={styles.topRight}>
|
||||
<View style={styles.row}>
|
||||
<StatusDot status={statusDotStatusMap[connectionStatus]} size={10} />
|
||||
<ThemedText preset="labelMd" style={styles.statusText}>
|
||||
{statusText}
|
||||
</ThemedText>
|
||||
</View>
|
||||
{fps > 0 && (
|
||||
<View style={styles.row}>
|
||||
<ThemedText preset="labelMd">{fps} FPS</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Bottom panel */}
|
||||
<View style={styles.bottomPanel}>
|
||||
<View style={styles.bottomCell}>
|
||||
<ThemedText preset="bodySm">RSSI</ThemedText>
|
||||
<ThemedText preset="displayMd" style={styles.bigValue}>
|
||||
{formatRssi(rssi)}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.bottomCell}>
|
||||
<ModeBadge mode={mode} />
|
||||
</View>
|
||||
|
||||
<View style={styles.bottomCellRight}>
|
||||
<ThemedText preset="bodySm">Confidence</ThemedText>
|
||||
<ThemedText preset="bodyMd" style={styles.metaText}>
|
||||
{formatConfidence(confidence)}
|
||||
</ThemedText>
|
||||
<ThemedText preset="bodySm">People: {personCount}</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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';
|
||||
|
||||
@@ -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<WebView | null>(null);
|
||||
const { lastFrame, connectionStatus, isSimulated } = usePoseStream();
|
||||
const bridge = useGaussianBridge(webViewRef);
|
||||
|
||||
const [webError, setWebError] = useState<string | null>(null);
|
||||
const [viewerKey, setViewerKey] = useState(0);
|
||||
const sendTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingFrameRef = useRef<SensingFrame | null>(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 (
|
||||
<ThemedView style={styles.fallbackWrap}>
|
||||
<ThemedText preset="bodyLg">Live visualization failed</ThemedText>
|
||||
<ThemedText preset="bodySm" color="textSecondary" style={styles.errorText}>
|
||||
{webError ?? bridge.error}
|
||||
</ThemedText>
|
||||
<Button title="Retry" onPress={handleRetry} />
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<View style={styles.container}>
|
||||
<GaussianSplatWebView
|
||||
key={viewerKey}
|
||||
webViewRef={webViewRef}
|
||||
onMessage={onMessage}
|
||||
onError={handleWebError}
|
||||
onLayout={onLayout}
|
||||
/>
|
||||
|
||||
<LiveHUD
|
||||
connectionStatus={connectionStatus}
|
||||
fps={bridge.fps}
|
||||
rssi={rssi}
|
||||
confidence={lastFrame?.classification?.confidence ?? 0}
|
||||
personCount={personCount}
|
||||
mode={mode}
|
||||
/>
|
||||
|
||||
{!bridge.isReady && (
|
||||
<View style={styles.loadingWrap}>
|
||||
<LoadingSpinner />
|
||||
<ThemedText preset="bodyMd" style={styles.loadingText}>
|
||||
Loading live renderer
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<WebView | null>) => {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [fps, setFps] = useState(0);
|
||||
const [error, setError] = useState<string | null>(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);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#111827',
|
||||
borderWidth: 1,
|
||||
borderColor: `${severity.color}55`,
|
||||
padding: spacing.md,
|
||||
borderRadius: 10,
|
||||
marginBottom: spacing.sm,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<ThemedText preset="labelMd" style={{ color: severity.color }}>
|
||||
{severity.icon} {severity.label}
|
||||
</ThemedText>
|
||||
<View style={{ flex: 1 }}>
|
||||
<ThemedText preset="bodySm" style={{ color: colors.textSecondary }}>
|
||||
{formatTime(alert.created_at)}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
<ThemedText preset="bodyMd" style={{ color: colors.textPrimary, marginTop: 6 }}>
|
||||
{alert.message}
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#111827',
|
||||
}}
|
||||
>
|
||||
<ThemedText preset="bodyMd">No alerts — system nominal</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={alerts}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => <AlertCard alert={item} />}
|
||||
contentContainerStyle={{ paddingBottom: spacing.md }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
removeClippedSubviews={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<WebView | null>;
|
||||
onMessage: (event: WebViewMessageEvent) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
export const MatWebView = ({ webViewRef, onMessage, style }: MatWebViewProps) => {
|
||||
return (
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
originWhitelist={["*"]}
|
||||
style={style}
|
||||
source={{ html: MAT_DASHBOARD_HTML }}
|
||||
onMessage={onMessage}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled
|
||||
mixedContentMode="always"
|
||||
overScrollMode="never"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 }) => (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#0D1117',
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
borderColor: `${color}55`,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
marginRight: spacing.sm,
|
||||
marginTop: spacing.sm,
|
||||
}}
|
||||
>
|
||||
<ThemedText preset="bodySm" style={{ color }}>
|
||||
{label}: {value}
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
|
||||
export const SurvivorCounter = ({ survivors }: SurvivorCounterProps) => {
|
||||
const total = survivors.length;
|
||||
const breakdown = getBreakdown(survivors);
|
||||
|
||||
return (
|
||||
<View style={{ paddingBottom: spacing.md }}>
|
||||
<ThemedText preset="displayLg" style={{ color: colors.textPrimary }}>
|
||||
{total} SURVIVORS DETECTED
|
||||
</ThemedText>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', marginTop: spacing.sm }}>
|
||||
<BreakoutChip label="Immediate" value={breakdown.immediate} color={colors.danger} />
|
||||
<BreakoutChip label="Delayed" value={breakdown.delayed} color={colors.warn} />
|
||||
<BreakoutChip label="Minimal" value={breakdown.minor} color={colors.success} />
|
||||
<BreakoutChip label="Expectant" value={breakdown.deceased} color={colors.textSecondary} />
|
||||
<BreakoutChip label="Unknown" value={breakdown.unknown} color="#a0aec0" />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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 (
|
||||
<ThemedView style={{ flex: 1, backgroundColor: colors.bg, padding: spacing.md }}>
|
||||
<ConnectionBanner status={resolveBannerState(connectionStatus)} />
|
||||
<View style={{ marginTop: 20 }}>
|
||||
<SurvivorCounter survivors={survivors} />
|
||||
</View>
|
||||
<View style={{ height: webHeight }}>
|
||||
<MatWebView
|
||||
webViewRef={webViewRef}
|
||||
onMessage={onMessage}
|
||||
style={{ flex: 1, borderRadius: 12, overflow: 'hidden', backgroundColor: colors.surface }}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ flex: 1, marginTop: spacing.md }}>
|
||||
<AlertList alerts={alerts} />
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
};
|
||||
|
||||
export default MATScreen;
|
||||
|
||||
@@ -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<WebView | null>(null);
|
||||
const isReadyRef = useRef(false);
|
||||
const queuedMessages = useRef<string[]>([]);
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<ThemedText preset="bodyMd">RSSI Scan</ThemedText>
|
||||
<ThemedText preset="bodySm" style={{ color: colors.textSecondary }}>
|
||||
Scan for nearby Wi-Fi signals from Android devices
|
||||
</ThemedText>
|
||||
</View>
|
||||
<Switch
|
||||
value={enabled}
|
||||
onValueChange={onChange}
|
||||
trackColor={{ true: colors.accent, false: colors.surfaceAlt }}
|
||||
thumbColor={colors.textPrimary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{Platform.OS === 'ios' && (
|
||||
<ThemedText preset="bodySm" style={{ color: colors.textSecondary, marginTop: spacing.xs }}>
|
||||
iOS: RSSI scan is currently limited — using stub data.
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<View>
|
||||
<ThemedText preset="labelMd" style={{ marginBottom: spacing.sm }}>
|
||||
Server URL
|
||||
</ThemedText>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
placeholder="http://192.168.1.100:8080"
|
||||
keyboardType="url"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: validation.valid ? colors.border : colors.danger,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.surface,
|
||||
color: colors.textPrimary,
|
||||
padding: spacing.sm,
|
||||
marginBottom: spacing.sm,
|
||||
}}
|
||||
/>
|
||||
{!validation.valid && (
|
||||
<ThemedText preset="bodySm" style={{ color: colors.danger, marginBottom: spacing.sm }}>
|
||||
{validation.error}
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
<ThemedText preset="bodySm" style={{ color: colors.textSecondary, marginBottom: spacing.sm }}>
|
||||
{testResult || 'Ready to test connection'}
|
||||
</ThemedText>
|
||||
|
||||
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
|
||||
<Pressable
|
||||
onPress={handleTest}
|
||||
disabled={!validation.valid}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
backgroundColor: validation.valid ? colors.accentDim : colors.surfaceAlt,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ThemedText preset="labelMd" style={{ color: colors.textPrimary }}>
|
||||
Test Connection
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onSave}
|
||||
disabled={!validation.valid}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
backgroundColor: validation.valid ? colors.success : colors.surfaceAlt,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ThemedText preset="labelMd" style={{ color: colors.textPrimary }}>
|
||||
Save
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.sm,
|
||||
}}
|
||||
>
|
||||
{OPTIONS.map((option) => {
|
||||
const isActive = option === value;
|
||||
return (
|
||||
<Pressable
|
||||
key={option}
|
||||
onPress={() => 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',
|
||||
}}
|
||||
>
|
||||
<ThemedText preset="labelMd" style={{ color: isActive ? colors.accent : colors.textSecondary }}>
|
||||
{option.toUpperCase()}
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#0F141E',
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.accent}35`,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.md,
|
||||
}}
|
||||
>
|
||||
<ThemedText preset="labelMd" style={{ marginBottom: spacing.sm, color: colors.textPrimary }}>
|
||||
{title}
|
||||
</ThemedText>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const ScanIntervalPicker = ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}) => {
|
||||
const options = [1, 2, 5];
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm }}>
|
||||
{options.map((interval) => {
|
||||
const isActive = interval === value;
|
||||
return (
|
||||
<Pressable
|
||||
key={interval}
|
||||
onPress={() => onChange(interval)}
|
||||
style={{
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: isActive ? colors.accent : colors.border,
|
||||
borderRadius: 8,
|
||||
backgroundColor: isActive ? `${colors.accent}20` : colors.surface,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ThemedText
|
||||
preset="bodySm"
|
||||
style={{
|
||||
color: isActive ? colors.accent : colors.textSecondary,
|
||||
paddingVertical: 8,
|
||||
}}
|
||||
>
|
||||
{interval}s
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<ThemedView style={{ flex: 1, backgroundColor: colors.bg, padding: spacing.md }}>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingBottom: spacing.xl,
|
||||
}}
|
||||
>
|
||||
<GlowCard title="SERVER">
|
||||
<ServerUrlInput value={draftUrl} onChange={setDraftUrl} onSave={handleSaveUrl} />
|
||||
</GlowCard>
|
||||
|
||||
<GlowCard title="SENSING">
|
||||
<RssiToggle enabled={rssiScanEnabled} onChange={setRssiScanEnabled} />
|
||||
<ThemedText preset="bodyMd" style={{ marginTop: spacing.md }}>
|
||||
Scan interval
|
||||
</ThemedText>
|
||||
<ScanIntervalPicker value={scanInterval} onChange={setScanInterval} />
|
||||
<ThemedText preset="bodySm" style={{ color: colors.textSecondary, marginTop: spacing.sm }}>
|
||||
Active interval: {intervalSummary}
|
||||
</ThemedText>
|
||||
{Platform.OS === 'ios' && (
|
||||
<ThemedText preset="bodySm" style={{ color: colors.textSecondary, marginTop: spacing.sm }}>
|
||||
iOS: RSSI scanning uses stubbed telemetry in this build.
|
||||
</ThemedText>
|
||||
)}
|
||||
</GlowCard>
|
||||
|
||||
<GlowCard title="APPEARANCE">
|
||||
<ThemePicker value={theme} onChange={setTheme} />
|
||||
</GlowCard>
|
||||
|
||||
<GlowCard title="ABOUT">
|
||||
<ThemedText preset="bodyMd" style={{ marginBottom: spacing.xs }}>
|
||||
WiFi-DensePose Mobile v1.0.0
|
||||
</ThemedText>
|
||||
<ThemedText
|
||||
preset="bodySm"
|
||||
style={{ color: colors.accent, marginBottom: spacing.sm }}
|
||||
onPress={handleOpenGitHub}
|
||||
>
|
||||
View on GitHub
|
||||
</ThemedText>
|
||||
<ThemedText preset="bodySm">WebSocket: {WS_PATH}</ThemedText>
|
||||
<ThemedText preset="bodySm" style={{ color: colors.textSecondary }}>
|
||||
Triage priority mapping: Immediate/Delayed/Minor/Deceased/Unknown
|
||||
</ThemedText>
|
||||
</GlowCard>
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsScreen;
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
<ThemedText preset="labelMd" style={styles.label}>
|
||||
BREATHING
|
||||
</ThemedText>
|
||||
<GaugeArc value={value} min={BREATHING_MIN_BPM} max={BREATHING_MAX_BPM} label="" unit="BPM" color={colors.accent} />
|
||||
<ThemedText preset="labelMd" color="textSecondary" style={styles.unit}>
|
||||
BPM
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
label: {
|
||||
color: '#94A3B8',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
unit: {
|
||||
marginTop: -12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
<ThemedText preset="labelMd" style={styles.label}>
|
||||
HR PROXY
|
||||
</ThemedText>
|
||||
<GaugeArc
|
||||
value={value}
|
||||
min={HEART_MIN_BPM}
|
||||
max={HEART_MAX_BPM}
|
||||
label=""
|
||||
unit="BPM"
|
||||
color={colors.danger}
|
||||
colorTo={colors.success}
|
||||
/>
|
||||
<ThemedText preset="bodySm" color="textSecondary" style={styles.note}>
|
||||
(estimated)
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
label: {
|
||||
color: '#94A3B8',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
note: {
|
||||
marginTop: -12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<View style={[styles.card, { borderColor: color, shadowColor: color, shadowOpacity: 0.35 }]} accessibilityRole="summary">
|
||||
<ThemedText preset="labelMd" style={styles.label}>
|
||||
{label}
|
||||
</ThemedText>
|
||||
<ThemedText preset="displayMd" style={styles.value}>
|
||||
{displayValue}
|
||||
</ThemedText>
|
||||
{sparklineData && sparklineData.length > 0 && (
|
||||
<View style={styles.sparklineWrap}>
|
||||
<SparklineChart data={sparklineData} color={color} height={56} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<ThemedView style={styles.screen}>
|
||||
<ConnectionBanner status={bannerStatus} />
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.headerRow}>{isSimulated ? <ModeBadge mode="SIM" /> : null}</View>
|
||||
|
||||
<View style={styles.gaugesRow}>
|
||||
<View style={styles.gaugeCard}>
|
||||
<BreathingGauge />
|
||||
</View>
|
||||
<View style={styles.gaugeCard}>
|
||||
<HeartRateGauge />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<ThemedText preset="labelLg" color="textSecondary">
|
||||
RSSI HISTORY
|
||||
</ThemedText>
|
||||
<SparklineChart data={rssiHistory.length > 0 ? rssiHistory : [0]} color={colors.accent} />
|
||||
</View>
|
||||
|
||||
<MetricCard label="Variance" value={features?.variance ?? 0} unit="" sparklineData={rssiHistory} color={colors.accent} />
|
||||
<MetricCard
|
||||
label="Motion Band"
|
||||
value={features?.motion_band_power ?? 0}
|
||||
unit=""
|
||||
color={colors.success}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Breath Band"
|
||||
value={features?.breathing_band_power ?? 0}
|
||||
unit=""
|
||||
color={colors.warn}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Spectral Entropy"
|
||||
value={features?.spectral_entropy ?? 0}
|
||||
unit=""
|
||||
color={colors.connected}
|
||||
/>
|
||||
|
||||
<View style={styles.classificationSection}>
|
||||
<ThemedText preset="labelLg" style={styles.rowLabel}>
|
||||
Classification: {badgeLabel}
|
||||
</ThemedText>
|
||||
<View style={[styles.badgePill, { borderColor: classificationColor, backgroundColor: `${classificationColor}18` }]}>
|
||||
<ThemedText preset="labelMd" style={{ color: classificationColor }}>
|
||||
{badgeLabel}
|
||||
</ThemedText>
|
||||
</View>
|
||||
<View style={styles.confidenceContainer}>
|
||||
<ThemedText preset="bodySm" color="textSecondary">
|
||||
Confidence
|
||||
</ThemedText>
|
||||
<View style={styles.confidenceBarTrack}>
|
||||
<Animated.View style={[styles.confidenceBarFill, animatedConfidenceStyle]} />
|
||||
</View>
|
||||
<ThemedText preset="bodySm">{Math.round(confidence * 100)}%</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<number[]>;
|
||||
progress: SharedValue<number>;
|
||||
}) => {
|
||||
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 <AnimatedRect x={x} y={y} width={cellSize} height={cellSize} rx={1} animatedProps={animatedProps} />;
|
||||
};
|
||||
|
||||
const RouterMarker = ({ cellSize }: { cellSize: number }) => {
|
||||
const cx = cellSize * 5.5;
|
||||
const cy = cellSize * 17.5;
|
||||
const radius = cellSize * 0.35;
|
||||
|
||||
return (
|
||||
<Polygon
|
||||
points={`${cx},${cy - radius} ${cx + radius},${cy} ${cx},${cy + radius} ${cx - radius},${cy}`}
|
||||
fill="rgba(50, 184, 198, 0.25)"
|
||||
stroke={colors.accent}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Circle
|
||||
key={`person-${idx}`}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={radius}
|
||||
fill={colors.accent}
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.concat(
|
||||
<RouterMarker key="router" cellSize={size / GRID_SIZE} />,
|
||||
);
|
||||
}, [personPositions, size]);
|
||||
|
||||
return (
|
||||
<View style={[{ overflow: 'hidden', paddingBottom: spacing.xs }, style]}>
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<AnimatedContainer style={panStyle}>
|
||||
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
{Array.from({ length: CELL_COUNT }).map((_, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
index={index}
|
||||
size={size}
|
||||
values={values}
|
||||
progress={progress}
|
||||
/>
|
||||
))}
|
||||
{markers}
|
||||
</Svg>
|
||||
</AnimatedContainer>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: spacing.md }}>
|
||||
{LEGEND_STOPS.map((stop) => (
|
||||
<View
|
||||
key={stop.label}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 3,
|
||||
backgroundColor: stop.color,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
}}
|
||||
/>
|
||||
<ThemedText preset="bodySm" style={{ color: colors.textSecondary }}>
|
||||
{stop.label}
|
||||
</ThemedText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<ThemedView style={{ flex: 1, backgroundColor: colors.bg }}>
|
||||
<ScrollView contentContainerStyle={{ padding: spacing.md, paddingBottom: spacing.xxl }}>
|
||||
<ConnectionBanner status={resolveBannerState(connectionStatus)} />
|
||||
<View
|
||||
style={{
|
||||
marginTop: 28,
|
||||
marginBottom: spacing.md,
|
||||
}}
|
||||
>
|
||||
<ThemedText preset="labelLg" style={{ color: colors.textSecondary, marginBottom: 8 }}>
|
||||
Floor Plan — Occupancy Heatmap
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<FloorPlanSvg
|
||||
gridValues={gridValues}
|
||||
personPositions={personPositions}
|
||||
size={mapSize}
|
||||
style={{ alignSelf: 'center' }}
|
||||
/>
|
||||
|
||||
<ZoneLegend />
|
||||
|
||||
<View
|
||||
style={{
|
||||
marginTop: spacing.md,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
}}
|
||||
>
|
||||
<ThemedText preset="bodyMd">Occupancy: {personPositions.length} persons detected</ThemedText>
|
||||
<ThemedText preset="bodyMd">Last update: {getLastUpdateSeconds(lastFrame?.timestamp)}</ThemedText>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZonesScreen;
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user