diff --git a/ui/mobile/src/__tests__/screens/ZonesOccupancy.test.ts b/ui/mobile/src/__tests__/screens/ZonesOccupancy.test.ts
new file mode 100644
index 000000000..44fee78e1
--- /dev/null
+++ b/ui/mobile/src/__tests__/screens/ZonesOccupancy.test.ts
@@ -0,0 +1,19 @@
+import { deriveFieldPositions } from '@/screens/ZonesScreen/useOccupancyGrid';
+
+describe('Zones hardware occupancy mapping', () => {
+ it('places an estimated person at the strongest field cell', () => {
+ const values = new Array(400).fill(0);
+ values[7 * 20 + 12] = 1;
+ expect(deriveFieldPositions(values, 1)).toEqual([{ x: 12, y: 7 }]);
+ });
+
+ it('keeps multiple estimated people spatially separated', () => {
+ const values = new Array(400).fill(0);
+ values[2 * 20 + 2] = 1;
+ values[15 * 20 + 16] = 0.9;
+ const positions = deriveFieldPositions(values, 2);
+ expect(positions).toHaveLength(2);
+ expect(positions).toContainEqual({ x: 2, y: 2 });
+ expect(positions).toContainEqual({ x: 16, y: 15 });
+ });
+});
diff --git a/ui/mobile/src/__tests__/services/api.service.test.ts b/ui/mobile/src/__tests__/services/api.service.test.ts
index b03a0991e..ece2fd2aa 100644
--- a/ui/mobile/src/__tests__/services/api.service.test.ts
+++ b/ui/mobile/src/__tests__/services/api.service.test.ts
@@ -90,7 +90,7 @@ describe('ApiService', () => {
it('returns response data on success', async () => {
apiService.setBaseUrl('http://localhost:3000');
mockRequest.mockResolvedValueOnce({ data: { status: 'ok' } });
- const result = await apiService.get('/api/v1/pose/status');
+ const result = await apiService.get('/api/v1/status');
expect(result).toEqual({ status: 'ok' });
});
@@ -113,7 +113,7 @@ describe('ApiService', () => {
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
method: 'GET',
- url: 'http://draft.example:8080/api/v1/pose/status',
+ url: 'http://draft.example:8080/api/v1/status',
}),
);
});
@@ -124,7 +124,7 @@ describe('ApiService', () => {
await apiService.getStatusAt('wss://draft.example');
expect(mockRequest).toHaveBeenCalledWith(
- expect.objectContaining({ url: 'https://draft.example/api/v1/pose/status' }),
+ expect.objectContaining({ url: 'https://draft.example/api/v1/status' }),
);
});
});
diff --git a/ui/mobile/src/__tests__/services/ws.service.test.ts b/ui/mobile/src/__tests__/services/ws.service.test.ts
index cd51350c4..2525e2667 100644
--- a/ui/mobile/src/__tests__/services/ws.service.test.ts
+++ b/ui/mobile/src/__tests__/services/ws.service.test.ts
@@ -69,7 +69,7 @@ describe('WsService', () => {
// Test with port 3000
ws.connect('http://192.168.1.10:3000');
- expect(capturedUrls[capturedUrls.length - 1]).toBe('ws://192.168.1.10:3000/api/v1/stream/pose');
+ expect(capturedUrls[capturedUrls.length - 1]).toBe('ws://192.168.1.10:3000/ws/sensing');
// Clean up, create another service
ws.disconnect();
@@ -77,19 +77,19 @@ describe('WsService', () => {
// Test with port 8080
ws2.connect('http://myserver.local:8080');
- expect(capturedUrls[capturedUrls.length - 1]).toBe('ws://myserver.local:8080/api/v1/stream/pose');
+ expect(capturedUrls[capturedUrls.length - 1]).toBe('ws://myserver.local:8080/ws/sensing');
ws2.disconnect();
// Test HTTPS -> WSS upgrade (port 443 is default for HTTPS so host drops it)
const ws3 = createWsService();
ws3.connect('https://secure.example.com:443');
- expect(capturedUrls[capturedUrls.length - 1]).toBe('wss://secure.example.com/api/v1/stream/pose');
+ expect(capturedUrls[capturedUrls.length - 1]).toBe('wss://secure.example.com/ws/sensing');
ws3.disconnect();
// Test WSS input
const ws4 = createWsService();
ws4.connect('wss://secure.example.com');
- expect(capturedUrls[capturedUrls.length - 1]).toBe('wss://secure.example.com/api/v1/stream/pose');
+ expect(capturedUrls[capturedUrls.length - 1]).toBe('wss://secure.example.com/ws/sensing');
ws4.disconnect();
// Verify port 3001 is NOT hardcoded anywhere
@@ -102,6 +102,20 @@ describe('WsService', () => {
});
});
+ describe('timestamp normalization', () => {
+ it('converts Rust Unix seconds to React Native milliseconds', () => {
+ const { normalizeFrameTimestamp } = require('@/services/ws.service');
+ const frame = { timestamp: 1_787_587_655, nodes: [], features: {}, classification: {}, signal_field: {} };
+ expect(normalizeFrameTimestamp(frame).timestamp).toBe(1_787_587_655_000);
+ });
+
+ it('leaves millisecond timestamps unchanged', () => {
+ const { normalizeFrameTimestamp } = require('@/services/ws.service');
+ const frame = { timestamp: 1_787_587_655_000, nodes: [], features: {}, classification: {}, signal_field: {} };
+ expect(normalizeFrameTimestamp(frame)).toBe(frame);
+ });
+ });
+
describe('connect with empty URL', () => {
it('falls back to simulation mode when URL is empty', () => {
const ws = createWsService();
@@ -114,7 +128,7 @@ describe('WsService', () => {
describe('connection fallback', () => {
it('uses simulation when an active server connection closes normally', () => {
const OrigWebSocket = globalThis.WebSocket;
- let socket: any;
+ const sockets: any[] = [];
class MockWebSocket {
static OPEN = 1;
@@ -126,7 +140,7 @@ describe('WsService', () => {
onmessage: (() => void) | null = null;
close() {}
constructor() {
- socket = this;
+ sockets.push(this);
}
}
@@ -134,7 +148,7 @@ describe('WsService', () => {
try {
const ws = createWsService();
ws.connect('http://localhost:3000');
- socket.onclose?.({ code: 1000 });
+ sockets[0].onclose?.({ code: 1000 });
expect(ws.getStatus()).toBe('simulated');
ws.disconnect();
@@ -178,6 +192,48 @@ describe('WsService', () => {
});
});
+ describe('hardware presentation rate', () => {
+ it('drops burst frames above 4 fps so native screens stay current', () => {
+ const OrigWebSocket = globalThis.WebSocket;
+ const sockets: any[] = [];
+
+ class MockWebSocket {
+ static OPEN = 1;
+ static CONNECTING = 0;
+ readyState = 0;
+ onopen: (() => void) | null = null;
+ onclose: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ onmessage: ((event: { data: string }) => void) | null = null;
+ close() {}
+ constructor() { sockets.push(this); }
+ }
+
+ globalThis.WebSocket = MockWebSocket as any;
+ try {
+ const ws = createWsService();
+ const listener = jest.fn();
+ ws.subscribe(listener);
+ ws.connect('http://localhost:3000');
+ sockets[0].onopen?.();
+ const frame = JSON.stringify({
+ timestamp: Date.now(), nodes: [], features: {}, classification: {}, signal_field: {},
+ });
+
+ sockets[0].onmessage?.({ data: frame });
+ sockets[0].onmessage?.({ data: frame });
+ expect(listener).toHaveBeenCalledTimes(1);
+
+ jest.advanceTimersByTime(250);
+ sockets[0].onmessage?.({ data: frame });
+ expect(listener).toHaveBeenCalledTimes(2);
+ ws.disconnect();
+ } finally {
+ globalThis.WebSocket = OrigWebSocket;
+ }
+ });
+ });
+
describe('subscribe and unsubscribe', () => {
it('adds a listener and returns an unsubscribe function', () => {
const ws = createWsService();
diff --git a/ui/mobile/src/__tests__/stores/matStore.test.ts b/ui/mobile/src/__tests__/stores/matStore.test.ts
index 5701db778..505359b97 100644
--- a/ui/mobile/src/__tests__/stores/matStore.test.ts
+++ b/ui/mobile/src/__tests__/stores/matStore.test.ts
@@ -67,6 +67,19 @@ describe('useMatStore', () => {
});
});
+ it('reconciles the authoritative survivor list from a hardware frame', () => {
+ const survivor = {
+ id: 'hardware-1', zone_id: 'zone-a', x: 1, y: 2, depth: -1,
+ triage_status: 2, triage_color: '#0f0', confidence: 0.8,
+ breathing_rate: 12, heart_rate: 72, first_detected: 'now',
+ last_updated: 'now', is_deteriorating: false,
+ };
+ useMatStore.getState().setSurvivors([survivor]);
+ expect(useMatStore.getState().survivors).toEqual([survivor]);
+ useMatStore.getState().setSurvivors([]);
+ expect(useMatStore.getState().survivors).toEqual([]);
+ });
+
describe('initial state', () => {
it('has empty events array', () => {
expect(useMatStore.getState().events).toEqual([]);
diff --git a/ui/mobile/src/assets/webview/mat-dashboard.html b/ui/mobile/src/assets/webview/mat-dashboard.html
index eafd444e4..e80f3c9c3 100644
--- a/ui/mobile/src/assets/webview/mat-dashboard.html
+++ b/ui/mobile/src/assets/webview/mat-dashboard.html
@@ -319,6 +319,25 @@
}
processFrame(frame) {
+ const estimated = Number.isFinite(frame?.estimated_persons)
+ ? Math.max(0, Math.min(16, Math.floor(frame.estimated_persons)))
+ : frame?.classification?.presence ? 1 : 0;
+
+ while (this.survivors.size > estimated) {
+ const lastId = [...this.survivors.keys()].pop();
+ if (lastId) this.survivors.delete(lastId);
+ }
+ while (this.survivors.size < estimated) {
+ this.processSurvivorDetection(
+ null,
+ Number(frame?.classification?.confidence || 0),
+ {
+ breathing_rate: frame?.vital_signs?.breathing_rate_bpm,
+ heart_rate: frame?.vital_signs?.heart_rate_bpm,
+ },
+ );
+ }
+
const motion = Number(frame?.features?.motion_band_power || 0);
const xDelta = isNumber(motion) ? (motion - 0.1) * 4 : 0;
const yDelta = isNumber(frame?.features?.breathing_band_power || 0)
@@ -333,6 +352,11 @@
survivor.y = Math.max(5, Math.min(280, survivor.y + this.motionVector.y + jitterY));
survivor.last_updated = new Date().toISOString();
}
+
+ this.postMessage({
+ type: 'SURVIVORS_SYNC',
+ payload: [...this.survivors.values()],
+ });
}
renderZones(ctx) {
@@ -445,9 +469,6 @@
dashboard.createEvent('earthquake', 37.7749, -122.4194, 'Training Scenario');
dashboard.addRectangleZone('Zone A', 60, 45, 170, 120);
dashboard.addCircleZone('Zone B', 300, 170, 70);
- dashboard.processSurvivorDetection('Zone A', 0.94, { breathing_rate: 11, hr: 128 });
- dashboard.processSurvivorDetection('Zone A', 0.88, { breathing_rate: 16, hr: 118 });
- dashboard.processSurvivorDetection('Zone B', 0.71, { breathing_rate: 9, hr: 142 });
status.textContent = 'MAT dashboard ready';
dashboard.postMessage({ type: 'READY' });
};
diff --git a/ui/mobile/src/constants/api.ts b/ui/mobile/src/constants/api.ts
index 4d6c07d43..4f5289aeb 100644
--- a/ui/mobile/src/constants/api.ts
+++ b/ui/mobile/src/constants/api.ts
@@ -1,6 +1,6 @@
export const API_ROOT = '/api/v1';
-export const API_POSE_STATUS_PATH = '/api/v1/pose/status';
+export const API_POSE_STATUS_PATH = '/api/v1/status';
export const API_POSE_FRAMES_PATH = '/api/v1/pose/frames';
export const API_POSE_ZONES_PATH = '/api/v1/pose/zones';
export const API_POSE_CURRENT_PATH = '/api/v1/pose/current';
diff --git a/ui/mobile/src/constants/websocket.ts b/ui/mobile/src/constants/websocket.ts
index 07d6e0fb7..fa6d2253a 100644
--- a/ui/mobile/src/constants/websocket.ts
+++ b/ui/mobile/src/constants/websocket.ts
@@ -1,3 +1,3 @@
-export const WS_PATH = '/api/v1/stream/pose';
+export const WS_PATH = '/ws/sensing';
export const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000];
export const MAX_RECONNECT_ATTEMPTS = 10;
diff --git a/ui/mobile/src/navigation/MainTabs.tsx b/ui/mobile/src/navigation/MainTabs.tsx
index f4fa6fefc..472ef402b 100644
--- a/ui/mobile/src/navigation/MainTabs.tsx
+++ b/ui/mobile/src/navigation/MainTabs.tsx
@@ -1,76 +1,18 @@
-import React, { Suspense } from 'react';
-import { ActivityIndicator, StyleSheet, View } from 'react-native';
+import React from 'react';
+import { StyleSheet, View } from 'react-native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ThemedText } from '../components/ThemedText';
-import { ThemedView } from '../components/ThemedView';
import { colors } from '../theme/colors';
import { useMatStore } from '../stores/matStore';
import { MainTabsParamList } from './types';
-
-const createPlaceholder = (label: string) => {
- const Placeholder = () => (
-
- {label} screen not implemented yet
-
- Placeholder shell
-
-
- );
- const LazyPlaceholder = React.lazy(async () => ({ default: Placeholder }));
-
- const Wrapped = () => (
-
-
-
- Loading {label}
-
-
- }
- >
-
-
- );
-
- return Wrapped;
-};
-
-const wrapLazy = (
- loader: () => Promise<{ default: React.ComponentType }>,
- label: string,
-) => {
- const fallback = createPlaceholder(label);
- return React.lazy(async () => {
- try {
- const module = await loader();
- if (module?.default) {
- return module;
- }
- } catch {
- // keep fallback for shell-only screens
- }
- return { default: fallback } as { default: React.ComponentType };
- });
-};
-
-const withSuspense = (Component: React.ComponentType) => {
- const SuspendedScreen = () => (
- }>
-
-
- );
- return SuspendedScreen;
-};
-
-const LiveScreen = withSuspense(wrapLazy(() => import('../screens/LiveScreen'), 'Live'));
-const NLOSScreen = withSuspense(wrapLazy(() => import('../screens/NLOSScreen'), 'NLOS'));
-const VitalsScreen = withSuspense(wrapLazy(() => import('../screens/VitalsScreen'), 'Vitals'));
-const ZonesScreen = withSuspense(wrapLazy(() => import('../screens/ZonesScreen'), 'Zones'));
-const MATScreen = withSuspense(wrapLazy(() => import('../screens/MATScreen'), 'MAT'));
-const SettingsScreen = withSuspense(wrapLazy(() => import('../screens/SettingsScreen'), 'Settings'));
+import LiveScreen from '../screens/LiveScreen';
+import NLOSScreen from '../screens/NLOSScreen';
+import VitalsScreen from '../screens/VitalsScreen';
+import ZonesScreen from '../screens/ZonesScreen';
+import MATScreen from '../screens/MATScreen';
+import SettingsScreen from '../screens/SettingsScreen';
const toIconName = (routeName: keyof MainTabsParamList) => {
switch (routeName) {
diff --git a/ui/mobile/src/screens/LiveScreen/index.tsx b/ui/mobile/src/screens/LiveScreen/index.tsx
index 9a0c818ab..7d20a68ec 100644
--- a/ui/mobile/src/screens/LiveScreen/index.tsx
+++ b/ui/mobile/src/screens/LiveScreen/index.tsx
@@ -107,7 +107,7 @@ export const LiveScreen = () => {
const handleRetry = useCallback(() => { setError(null); setReady(false); setFps(0); setViewerKey((v) => v + 1); }, []);
const rssi = lastFrame?.features?.mean_rssi;
- const personCount = lastFrame?.classification?.presence ? 1 : 0;
+ const personCount = lastFrame?.estimated_persons ?? (lastFrame?.classification?.presence ? 1 : 0);
const mode = getMode(connectionStatus, isSimulated, lastFrame);
if (error) {
diff --git a/ui/mobile/src/screens/MATScreen/index.tsx b/ui/mobile/src/screens/MATScreen/index.tsx
index 7aafb3ae2..7bfee7521 100644
--- a/ui/mobile/src/screens/MATScreen/index.tsx
+++ b/ui/mobile/src/screens/MATScreen/index.tsx
@@ -47,6 +47,7 @@ export const MATScreen = () => {
const survivors = useMatStore((state) => state.survivors);
const alerts = useMatStore((state) => state.alerts);
const upsertSurvivor = useMatStore((state) => state.upsertSurvivor);
+ const setSurvivors = useMatStore((state) => state.setSurvivors);
const addAlert = useMatStore((state) => state.addAlert);
const upsertEvent = useMatStore((state) => state.upsertEvent);
const dataSource = useMatStore((state) => state.dataSource);
@@ -65,6 +66,9 @@ export const MATScreen = () => {
upsertSurvivor(survivor);
}
},
+ onSurvivorsSync: (nextSurvivors) => {
+ setSurvivors(nextSurvivors.filter(isSurvivor));
+ },
onAlertGenerated: (alert) => {
if (isAlert(alert)) {
addAlert(alert);
diff --git a/ui/mobile/src/screens/MATScreen/useMatBridge.ts b/ui/mobile/src/screens/MATScreen/useMatBridge.ts
index b502f4219..57b2f2be7 100644
--- a/ui/mobile/src/screens/MATScreen/useMatBridge.ts
+++ b/ui/mobile/src/screens/MATScreen/useMatBridge.ts
@@ -5,7 +5,7 @@ import type { SensingFrame } from '@/types/sensing';
type MatBridgeMessageType = 'CREATE_EVENT' | 'ADD_ZONE' | 'FRAME_UPDATE';
-type MatIncomingType = 'READY' | 'SURVIVOR_DETECTED' | 'ALERT_GENERATED';
+type MatIncomingType = 'READY' | 'SURVIVOR_DETECTED' | 'SURVIVORS_SYNC' | 'ALERT_GENERATED';
type MatIncomingMessage = {
type: MatIncomingType;
@@ -19,6 +19,7 @@ type MatOutgoingMessage = {
type UseMatBridgeOptions = {
onSurvivorDetected?: (survivor: Survivor) => void;
+ onSurvivorsSync?: (survivors: Survivor[]) => void;
onAlertGenerated?: (alert: Alert) => void;
};
@@ -30,7 +31,7 @@ const safeParseJson = (value: string): unknown | null => {
}
};
-export const useMatBridge = ({ onAlertGenerated, onSurvivorDetected }: UseMatBridgeOptions = {}) => {
+export const useMatBridge = ({ onAlertGenerated, onSurvivorDetected, onSurvivorsSync }: UseMatBridgeOptions = {}) => {
const webViewRef = useRef(null);
const isReadyRef = useRef(false);
const queuedMessages = useRef([]);
@@ -100,11 +101,18 @@ export const useMatBridge = ({ onAlertGenerated, onSurvivorDetected }: UseMatBri
return;
}
+ if (message.type === 'SURVIVORS_SYNC') {
+ if (Array.isArray(message.payload)) {
+ onSurvivorsSync?.(message.payload as Survivor[]);
+ }
+ return;
+ }
+
if (message.type === 'ALERT_GENERATED') {
onAlertGenerated?.(message.payload as Alert);
}
},
- [flush, onAlertGenerated, onSurvivorDetected],
+ [flush, onAlertGenerated, onSurvivorDetected, onSurvivorsSync],
);
return {
diff --git a/ui/mobile/src/screens/VitalsScreen/BreathingGauge.tsx b/ui/mobile/src/screens/VitalsScreen/BreathingGauge.tsx
index 5795a7602..0bc70e742 100644
--- a/ui/mobile/src/screens/VitalsScreen/BreathingGauge.tsx
+++ b/ui/mobile/src/screens/VitalsScreen/BreathingGauge.tsx
@@ -26,7 +26,10 @@ const deriveBreathingValue = (
export const BreathingGauge = () => {
const breathingBand = usePoseStore((state) => state.features?.breathing_band_power);
- const breathingBpm = usePoseStore((state) => state.lastFrame?.vital_signs?.breathing_bpm);
+ const breathingBpm = usePoseStore((state) => {
+ const vitals = state.lastFrame?.vital_signs;
+ return vitals?.breathing_bpm ?? vitals?.breathing_rate_bpm;
+ });
const value = useMemo(
() => deriveBreathingValue(breathingBand, breathingBpm),
diff --git a/ui/mobile/src/screens/VitalsScreen/HeartRateGauge.tsx b/ui/mobile/src/screens/VitalsScreen/HeartRateGauge.tsx
index c1060665a..6afaa9353 100644
--- a/ui/mobile/src/screens/VitalsScreen/HeartRateGauge.tsx
+++ b/ui/mobile/src/screens/VitalsScreen/HeartRateGauge.tsx
@@ -29,7 +29,10 @@ const deriveHeartRate = (
};
export const HeartRateGauge = () => {
- const heartProxyBpm = usePoseStore((state) => state.lastFrame?.vital_signs?.hr_proxy_bpm);
+ const heartProxyBpm = usePoseStore((state) => {
+ const vitals = state.lastFrame?.vital_signs;
+ return vitals?.hr_proxy_bpm ?? vitals?.heart_rate_bpm;
+ });
const motionBand = usePoseStore((state) => state.features?.motion_band_power);
const breathingBand = usePoseStore((state) => state.features?.breathing_band_power);
diff --git a/ui/mobile/src/screens/VitalsScreen/index.tsx b/ui/mobile/src/screens/VitalsScreen/index.tsx
index 55bdf3ce2..c6a4390aa 100644
--- a/ui/mobile/src/screens/VitalsScreen/index.tsx
+++ b/ui/mobile/src/screens/VitalsScreen/index.tsx
@@ -49,7 +49,7 @@ export default function VitalsScreen() {
}));
const classificationColor =
- classification?.motion_level === 'active'
+ classification?.motion_level === 'active' || classification?.motion_level === 'present_moving'
? colors.success
: classification?.motion_level === 'present_still'
? colors.warn
diff --git a/ui/mobile/src/screens/ZonesScreen/index.tsx b/ui/mobile/src/screens/ZonesScreen/index.tsx
index 9565d1e20..82166ca55 100644
--- a/ui/mobile/src/screens/ZonesScreen/index.tsx
+++ b/ui/mobile/src/screens/ZonesScreen/index.tsx
@@ -34,7 +34,8 @@ export const ZonesScreen = () => {
const lastFrame = usePoseStore((state) => state.lastFrame);
const signalField = usePoseStore((state) => state.signalField);
- const { gridValues, personPositions } = useOccupancyGrid(signalField);
+ const estimatedPersonCount = lastFrame?.estimated_persons ?? (lastFrame?.classification?.presence ? 1 : 0);
+ const { gridValues, personPositions } = useOccupancyGrid(signalField, estimatedPersonCount);
const { width } = useWindowDimensions();
const mapSize = useMemo(() => Math.max(240, Math.min(width - spacing.md * 2, 520)), [width]);
diff --git a/ui/mobile/src/screens/ZonesScreen/useOccupancyGrid.ts b/ui/mobile/src/screens/ZonesScreen/useOccupancyGrid.ts
index 4601bdd7a..2e8273278 100644
--- a/ui/mobile/src/screens/ZonesScreen/useOccupancyGrid.ts
+++ b/ui/mobile/src/screens/ZonesScreen/useOccupancyGrid.ts
@@ -66,7 +66,36 @@ const readClassificationPositions = (classification: Classification | undefined)
);
};
-export const useOccupancyGrid = (signalField: SignalField | null): { gridValues: number[]; personPositions: Point[] } => {
+export const deriveFieldPositions = (values: number[], requestedCount: number): Point[] => {
+ const count = Math.max(0, Math.min(16, Math.floor(requestedCount)));
+ if (count === 0 || values.length === 0) return [];
+
+ const candidates = values
+ .slice(0, CELL_COUNT)
+ .map((value, index) => ({
+ x: index % GRID_SIZE,
+ y: Math.floor(index / GRID_SIZE),
+ value: parseNumber(value) ?? 0,
+ }))
+ .sort((a, b) => b.value - a.value);
+
+ const positions: Point[] = [];
+ for (const candidate of candidates) {
+ const separated = positions.every(({ x, y }) => {
+ const dx = candidate.x - x;
+ const dy = candidate.y - y;
+ return dx * dx + dy * dy >= 9;
+ });
+ if (separated) positions.push({ x: candidate.x, y: candidate.y });
+ if (positions.length === count) break;
+ }
+ return positions;
+};
+
+export const useOccupancyGrid = (
+ signalField: SignalField | null,
+ estimatedPersonCount = 0,
+): { gridValues: number[]; personPositions: Point[] } => {
const classification = usePoseStore((state) => state.classification) as Classification | undefined;
const gridValues = useMemo(() => {
@@ -99,8 +128,8 @@ export const useOccupancyGrid = (signalField: SignalField | null): { gridValues:
.slice(0, 16);
}
- return [] as Point[];
- }, [classification]);
+ return deriveFieldPositions(gridValues, estimatedPersonCount);
+ }, [classification, estimatedPersonCount, gridValues]);
return {
gridValues,
diff --git a/ui/mobile/src/services/ws.service.ts b/ui/mobile/src/services/ws.service.ts
index b42448328..a1602b2e5 100644
--- a/ui/mobile/src/services/ws.service.ts
+++ b/ui/mobile/src/services/ws.service.ts
@@ -5,6 +5,22 @@ import { generateSimulatedData } from '@/services/simulation.service';
import type { ConnectionStatus, SensingFrame } from '@/types/sensing';
type FrameListener = (frame: SensingFrame) => void;
+const PRESENTATION_FRAME_INTERVAL_MS = 250;
+
+export const normalizeFrameTimestamp = (frame: SensingFrame): SensingFrame => {
+ const timestamp = frame.timestamp;
+ if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) {
+ return frame;
+ }
+
+ // The Rust sensing server emits Unix seconds while simulation and React
+ // Native use Unix milliseconds. Keep one unit inside the mobile app.
+ if (timestamp > 0 && timestamp < 100_000_000_000) {
+ return { ...frame, timestamp: timestamp * 1000 };
+ }
+
+ return frame;
+};
class WsService {
private ws: WebSocket | null = null;
@@ -15,6 +31,7 @@ class WsService {
private targetUrl = '';
private active = false;
private status: ConnectionStatus = 'disconnected';
+ private lastFrameDeliveryAt = 0;
connect(url: string): void {
const wasActive = this.active;
@@ -55,6 +72,7 @@ class WsService {
socket.onopen = () => {
this.reconnectAttempt = 0;
+ this.lastFrameDeliveryAt = 0;
this.stopSimulation();
this.handleStatusChange('connected');
};
@@ -62,7 +80,12 @@ class WsService {
socket.onmessage = (evt) => {
try {
const raw = typeof evt.data === 'string' ? evt.data : JSON.stringify(evt.data);
- const frame = JSON.parse(raw) as SensingFrame;
+ const frame = normalizeFrameTimestamp(JSON.parse(raw) as SensingFrame);
+ const now = Date.now();
+ if (now - this.lastFrameDeliveryAt < PRESENTATION_FRAME_INTERVAL_MS) {
+ return;
+ }
+ this.lastFrameDeliveryAt = now;
this.listeners.forEach((listener) => listener(frame));
} catch {
// ignore malformed frames
@@ -73,7 +96,7 @@ class WsService {
// handled by onclose
};
- socket.onclose = (evt) => {
+ socket.onclose = () => {
this.ws = null;
if (!this.active) {
this.handleStatusChange('disconnected');
diff --git a/ui/mobile/src/stores/matStore.ts b/ui/mobile/src/stores/matStore.ts
index 64bfbfddd..167983d0c 100644
--- a/ui/mobile/src/stores/matStore.ts
+++ b/ui/mobile/src/stores/matStore.ts
@@ -14,6 +14,7 @@ export interface MatState {
upsertEvent: (event: DisasterEvent) => void;
addZone: (zone: ScanZone) => void;
upsertSurvivor: (survivor: Survivor) => void;
+ setSurvivors: (survivors: Survivor[]) => void;
addAlert: (alert: Alert) => void;
setSelectedEvent: (id: string | null) => void;
setDataSource: (source: 'real' | 'simulated') => void;
@@ -65,6 +66,10 @@ export const useMatStore = create((set) => ({
});
},
+ setSurvivors: (survivors) => {
+ set({ survivors });
+ },
+
addAlert: (alert) => {
set((state) => {
if (state.alerts.some((item) => item.id === alert.id)) {
diff --git a/ui/mobile/src/types/sensing.ts b/ui/mobile/src/types/sensing.ts
index 822623b5c..ee3c3bed7 100644
--- a/ui/mobile/src/types/sensing.ts
+++ b/ui/mobile/src/types/sensing.ts
@@ -21,7 +21,7 @@ export interface FeatureSet {
}
export interface Classification {
- motion_level: 'absent' | 'present_still' | 'active';
+ motion_level: 'absent' | 'present_still' | 'present_moving' | 'active';
presence: boolean;
confidence: number;
}
@@ -54,6 +54,7 @@ export interface PersonDetection {
id?: number;
confidence: number;
keypoints: PoseKeypoint[];
+ position?: [number, number, number];
}
export interface SensingFrame {