diff --git a/mobile/src/assets/webview/mat-dashboard.html b/mobile/src/assets/webview/mat-dashboard.html
index e69de29..eafd444 100644
--- a/mobile/src/assets/webview/mat-dashboard.html
+++ b/mobile/src/assets/webview/mat-dashboard.html
@@ -0,0 +1,505 @@
+
+
+
+
+
+ MAT Dashboard
+
+
+
+
+
Initializing MAT dashboard...
+
+
+
+
+
+
diff --git a/mobile/src/screens/MATScreen/AlertCard.tsx b/mobile/src/screens/MATScreen/AlertCard.tsx
index e69de29..01c586d 100644
--- a/mobile/src/screens/MATScreen/AlertCard.tsx
+++ b/mobile/src/screens/MATScreen/AlertCard.tsx
@@ -0,0 +1,84 @@
+import { View } from 'react-native';
+import { ThemedText } from '@/components/ThemedText';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+import { AlertPriority, type Alert } from '@/types/mat';
+
+type SeverityLevel = 'URGENT' | 'HIGH' | 'NORMAL';
+
+type AlertCardProps = {
+ alert: Alert;
+};
+
+type SeverityMeta = {
+ label: SeverityLevel;
+ icon: string;
+ color: string;
+};
+
+const resolveSeverity = (alert: Alert): SeverityMeta => {
+ if (alert.priority === AlertPriority.Critical) {
+ return {
+ label: 'URGENT',
+ icon: '‼',
+ color: colors.danger,
+ };
+ }
+
+ if (alert.priority === AlertPriority.High) {
+ return {
+ label: 'HIGH',
+ icon: '⚠',
+ color: colors.warn,
+ };
+ }
+
+ return {
+ label: 'NORMAL',
+ icon: '•',
+ color: colors.accent,
+ };
+};
+
+const formatTime = (value?: string): string => {
+ if (!value) {
+ return 'Unknown';
+ }
+
+ try {
+ return new Date(value).toLocaleTimeString();
+ } catch {
+ return 'Unknown';
+ }
+};
+
+export const AlertCard = ({ alert }: AlertCardProps) => {
+ const severity = resolveSeverity(alert);
+
+ return (
+
+
+
+ {severity.icon} {severity.label}
+
+
+
+ {formatTime(alert.created_at)}
+
+
+
+
+ {alert.message}
+
+
+ );
+};
diff --git a/mobile/src/screens/MATScreen/AlertList.tsx b/mobile/src/screens/MATScreen/AlertList.tsx
index e69de29..405d9df 100644
--- a/mobile/src/screens/MATScreen/AlertList.tsx
+++ b/mobile/src/screens/MATScreen/AlertList.tsx
@@ -0,0 +1,41 @@
+import { FlatList, View } from 'react-native';
+import { ThemedText } from '@/components/ThemedText';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+import type { Alert } from '@/types/mat';
+import { AlertCard } from './AlertCard';
+
+type AlertListProps = {
+ alerts: Alert[];
+};
+
+export const AlertList = ({ alerts }: AlertListProps) => {
+ if (alerts.length === 0) {
+ return (
+
+ No alerts — system nominal
+
+ );
+ }
+
+ return (
+ item.id}
+ renderItem={({ item }) => }
+ contentContainerStyle={{ paddingBottom: spacing.md }}
+ showsVerticalScrollIndicator={false}
+ removeClippedSubviews={false}
+ />
+ );
+};
diff --git a/mobile/src/screens/MATScreen/MatWebView.tsx b/mobile/src/screens/MATScreen/MatWebView.tsx
index e69de29..c9413dc 100644
--- a/mobile/src/screens/MATScreen/MatWebView.tsx
+++ b/mobile/src/screens/MATScreen/MatWebView.tsx
@@ -0,0 +1,26 @@
+import { StyleProp, ViewStyle } from 'react-native';
+import WebView, { type WebViewMessageEvent } from 'react-native-webview';
+import type { RefObject } from 'react';
+import MAT_DASHBOARD_HTML from '@/assets/webview/mat-dashboard.html';
+
+type MatWebViewProps = {
+ webViewRef: RefObject;
+ onMessage: (event: WebViewMessageEvent) => void;
+ style?: StyleProp;
+};
+
+export const MatWebView = ({ webViewRef, onMessage, style }: MatWebViewProps) => {
+ return (
+
+ );
+};
diff --git a/mobile/src/screens/MATScreen/SurvivorCounter.tsx b/mobile/src/screens/MATScreen/SurvivorCounter.tsx
index e69de29..ccedd28 100644
--- a/mobile/src/screens/MATScreen/SurvivorCounter.tsx
+++ b/mobile/src/screens/MATScreen/SurvivorCounter.tsx
@@ -0,0 +1,89 @@
+import { View } from 'react-native';
+import { ThemedText } from '@/components/ThemedText';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+import { TriageStatus, type Survivor } from '@/types/mat';
+
+type SurvivorCounterProps = {
+ survivors: Survivor[];
+};
+
+type Breakdown = {
+ immediate: number;
+ delayed: number;
+ minor: number;
+ deceased: number;
+ unknown: number;
+};
+
+const getBreakdown = (survivors: Survivor[]): Breakdown => {
+ const output = {
+ immediate: 0,
+ delayed: 0,
+ minor: 0,
+ deceased: 0,
+ unknown: 0,
+ };
+
+ survivors.forEach((survivor) => {
+ if (survivor.triage_status === TriageStatus.Immediate) {
+ output.immediate += 1;
+ return;
+ }
+ if (survivor.triage_status === TriageStatus.Delayed) {
+ output.delayed += 1;
+ return;
+ }
+ if (survivor.triage_status === TriageStatus.Minor) {
+ output.minor += 1;
+ return;
+ }
+ if (survivor.triage_status === TriageStatus.Deceased) {
+ output.deceased += 1;
+ return;
+ }
+
+ output.unknown += 1;
+ });
+
+ return output;
+};
+
+const BreakoutChip = ({ label, value, color }: { label: string; value: number; color: string }) => (
+
+
+ {label}: {value}
+
+
+);
+
+export const SurvivorCounter = ({ survivors }: SurvivorCounterProps) => {
+ const total = survivors.length;
+ const breakdown = getBreakdown(survivors);
+
+ return (
+
+
+ {total} SURVIVORS DETECTED
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/mobile/src/screens/MATScreen/index.tsx b/mobile/src/screens/MATScreen/index.tsx
index e69de29..6c4a1cd 100644
--- a/mobile/src/screens/MATScreen/index.tsx
+++ b/mobile/src/screens/MATScreen/index.tsx
@@ -0,0 +1,138 @@
+import { useEffect, useRef } from 'react';
+import { useWindowDimensions, View } from 'react-native';
+import { ConnectionBanner } from '@/components/ConnectionBanner';
+import { ThemedView } from '@/components/ThemedView';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+import { usePoseStream } from '@/hooks/usePoseStream';
+import { useMatStore } from '@/stores/matStore';
+import { type ConnectionStatus } from '@/types/sensing';
+import { Alert, type Survivor } from '@/types/mat';
+import { AlertList } from './AlertList';
+import { MatWebView } from './MatWebView';
+import { SurvivorCounter } from './SurvivorCounter';
+import { useMatBridge } from './useMatBridge';
+
+const isAlert = (value: unknown): value is Alert => {
+ if (!value || typeof value !== 'object') {
+ return false;
+ }
+
+ const record = value as Record;
+ return typeof record.id === 'string' && typeof record.message === 'string';
+};
+
+const isSurvivor = (value: unknown): value is Survivor => {
+ if (!value || typeof value !== 'object') {
+ return false;
+ }
+
+ const record = value as Record;
+ return typeof record.id === 'string' && typeof record.zone_id === 'string';
+};
+
+const resolveBannerState = (status: ConnectionStatus): 'connected' | 'simulated' | 'disconnected' => {
+ if (status === 'connecting') {
+ return 'disconnected';
+ }
+
+ return status;
+};
+
+export const MATScreen = () => {
+ const { connectionStatus, lastFrame } = usePoseStream();
+
+ const { survivors, alerts, upsertSurvivor, addAlert, upsertEvent } = useMatStore((state) => ({
+ survivors: state.survivors,
+ alerts: state.alerts,
+ upsertSurvivor: state.upsertSurvivor,
+ addAlert: state.addAlert,
+ upsertEvent: state.upsertEvent,
+ }));
+
+ const { webViewRef, ready, onMessage, sendFrameUpdate, postEvent } = useMatBridge({
+ onSurvivorDetected: (survivor) => {
+ if (isSurvivor(survivor)) {
+ upsertSurvivor(survivor);
+ }
+ },
+ onAlertGenerated: (alert) => {
+ if (isAlert(alert)) {
+ addAlert(alert);
+ }
+ },
+ });
+
+ const seededRef = useRef(false);
+
+ useEffect(() => {
+ if (!ready || seededRef.current) {
+ return;
+ }
+
+ const createEvent = postEvent('CREATE_EVENT');
+ createEvent({
+ type: 'earthquake',
+ latitude: 37.7749,
+ longitude: -122.4194,
+ name: 'Training Scenario',
+ });
+
+ const addZone = postEvent('ADD_ZONE');
+ addZone({
+ name: 'Zone A',
+ zone_type: 'rectangle',
+ x: 60,
+ y: 60,
+ width: 180,
+ height: 120,
+ });
+ addZone({
+ name: 'Zone B',
+ zone_type: 'circle',
+ center_x: 300,
+ center_y: 170,
+ radius: 60,
+ });
+
+ upsertEvent({
+ event_id: 'training-scenario',
+ disaster_type: 1,
+ latitude: 37.7749,
+ longitude: -122.4194,
+ description: 'Training Scenario',
+ });
+
+ seededRef.current = true;
+ }, [postEvent, upsertEvent, ready]);
+
+ useEffect(() => {
+ if (ready && lastFrame) {
+ sendFrameUpdate(lastFrame);
+ }
+ }, [lastFrame, ready, sendFrameUpdate]);
+
+ const { height } = useWindowDimensions();
+ const webHeight = Math.max(240, Math.floor(height * 0.5));
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default MATScreen;
diff --git a/mobile/src/screens/MATScreen/useMatBridge.ts b/mobile/src/screens/MATScreen/useMatBridge.ts
index e69de29..b502f42 100644
--- a/mobile/src/screens/MATScreen/useMatBridge.ts
+++ b/mobile/src/screens/MATScreen/useMatBridge.ts
@@ -0,0 +1,118 @@
+import { useCallback, useRef, useState } from 'react';
+import type { WebView, WebViewMessageEvent } from 'react-native-webview';
+import type { Alert, Survivor } from '@/types/mat';
+import type { SensingFrame } from '@/types/sensing';
+
+type MatBridgeMessageType = 'CREATE_EVENT' | 'ADD_ZONE' | 'FRAME_UPDATE';
+
+type MatIncomingType = 'READY' | 'SURVIVOR_DETECTED' | 'ALERT_GENERATED';
+
+type MatIncomingMessage = {
+ type: MatIncomingType;
+ payload?: unknown;
+};
+
+type MatOutgoingMessage = {
+ type: MatBridgeMessageType;
+ payload?: unknown;
+};
+
+type UseMatBridgeOptions = {
+ onSurvivorDetected?: (survivor: Survivor) => void;
+ onAlertGenerated?: (alert: Alert) => void;
+};
+
+const safeParseJson = (value: string): unknown | null => {
+ try {
+ return JSON.parse(value);
+ } catch {
+ return null;
+ }
+};
+
+export const useMatBridge = ({ onAlertGenerated, onSurvivorDetected }: UseMatBridgeOptions = {}) => {
+ const webViewRef = useRef(null);
+ const isReadyRef = useRef(false);
+ const queuedMessages = useRef([]);
+ const [ready, setReady] = useState(false);
+
+ const flush = useCallback(() => {
+ if (!webViewRef.current || !isReadyRef.current) {
+ return;
+ }
+
+ while (queuedMessages.current.length > 0) {
+ const payload = queuedMessages.current.shift();
+ if (payload) {
+ webViewRef.current.postMessage(payload);
+ }
+ }
+ }, []);
+
+ const sendMessage = useCallback(
+ (message: MatOutgoingMessage) => {
+ const payload = JSON.stringify(message);
+ if (isReadyRef.current && webViewRef.current) {
+ webViewRef.current.postMessage(payload);
+ return;
+ }
+ queuedMessages.current.push(payload);
+ },
+ [],
+ );
+
+ const sendFrameUpdate = useCallback(
+ (frame: SensingFrame) => {
+ sendMessage({ type: 'FRAME_UPDATE', payload: frame });
+ },
+ [sendMessage],
+ );
+
+ const postEvent = useCallback(
+ (type: 'CREATE_EVENT' | 'ADD_ZONE') => {
+ return (payload: unknown) => {
+ sendMessage({
+ type,
+ payload,
+ });
+ };
+ },
+ [sendMessage],
+ );
+
+ const onMessage = useCallback(
+ (event: WebViewMessageEvent) => {
+ const payload = safeParseJson(event.nativeEvent.data);
+ if (!payload || typeof payload !== 'object') {
+ return;
+ }
+
+ const message = payload as MatIncomingMessage;
+ if (message.type === 'READY') {
+ isReadyRef.current = true;
+ setReady(true);
+ flush();
+ return;
+ }
+
+ if (message.type === 'SURVIVOR_DETECTED') {
+ onSurvivorDetected?.(message.payload as Survivor);
+ return;
+ }
+
+ if (message.type === 'ALERT_GENERATED') {
+ onAlertGenerated?.(message.payload as Alert);
+ }
+ },
+ [flush, onAlertGenerated, onSurvivorDetected],
+ );
+
+ return {
+ webViewRef,
+ ready,
+ onMessage,
+ sendMessage,
+ sendFrameUpdate,
+ postEvent,
+ };
+};
diff --git a/mobile/src/screens/SettingsScreen/RssiToggle.tsx b/mobile/src/screens/SettingsScreen/RssiToggle.tsx
index e69de29..c2f68a8 100644
--- a/mobile/src/screens/SettingsScreen/RssiToggle.tsx
+++ b/mobile/src/screens/SettingsScreen/RssiToggle.tsx
@@ -0,0 +1,36 @@
+import { Platform, Switch, View } from 'react-native';
+import { ThemedText } from '@/components/ThemedText';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+
+type RssiToggleProps = {
+ enabled: boolean;
+ onChange: (value: boolean) => void;
+};
+
+export const RssiToggle = ({ enabled, onChange }: RssiToggleProps) => {
+ return (
+
+
+
+ RSSI Scan
+
+ Scan for nearby Wi-Fi signals from Android devices
+
+
+
+
+
+ {Platform.OS === 'ios' && (
+
+ iOS: RSSI scan is currently limited — using stub data.
+
+ )}
+
+ );
+};
diff --git a/mobile/src/screens/SettingsScreen/ServerUrlInput.tsx b/mobile/src/screens/SettingsScreen/ServerUrlInput.tsx
index e69de29..79a4cc9 100644
--- a/mobile/src/screens/SettingsScreen/ServerUrlInput.tsx
+++ b/mobile/src/screens/SettingsScreen/ServerUrlInput.tsx
@@ -0,0 +1,102 @@
+import { useState } from 'react';
+import { Pressable, TextInput, View } from 'react-native';
+import { validateServerUrl } from '@/utils/urlValidator';
+import { apiService } from '@/services/api.service';
+import { ThemedText } from '@/components/ThemedText';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+
+type ServerUrlInputProps = {
+ value: string;
+ onChange: (value: string) => void;
+ onSave: () => void;
+};
+
+export const ServerUrlInput = ({ value, onChange, onSave }: ServerUrlInputProps) => {
+ const [testResult, setTestResult] = useState('');
+
+ const validation = validateServerUrl(value);
+
+ const handleTest = async () => {
+ if (!validation.valid) {
+ setTestResult('✗ Invalid URL');
+ return;
+ }
+
+ const start = Date.now();
+ try {
+ await apiService.getStatus();
+ setTestResult(`✓ ${Date.now() - start}ms`);
+ } catch {
+ setTestResult('✗ Failed');
+ }
+ };
+
+ return (
+
+
+ Server URL
+
+
+ {!validation.valid && (
+
+ {validation.error}
+
+ )}
+
+
+ {testResult || 'Ready to test connection'}
+
+
+
+
+
+ Test Connection
+
+
+
+
+ Save
+
+
+
+
+ );
+};
diff --git a/mobile/src/screens/SettingsScreen/ThemePicker.tsx b/mobile/src/screens/SettingsScreen/ThemePicker.tsx
index e69de29..bc0ae15 100644
--- a/mobile/src/screens/SettingsScreen/ThemePicker.tsx
+++ b/mobile/src/screens/SettingsScreen/ThemePicker.tsx
@@ -0,0 +1,47 @@
+import { Pressable, View } from 'react-native';
+import { ThemeMode } from '@/theme/ThemeContext';
+import { ThemedText } from '@/components/ThemedText';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+
+type ThemePickerProps = {
+ value: ThemeMode;
+ onChange: (value: ThemeMode) => void;
+};
+
+const OPTIONS: ThemeMode[] = ['light', 'dark', 'system'];
+
+export const ThemePicker = ({ value, onChange }: ThemePickerProps) => {
+ return (
+
+ {OPTIONS.map((option) => {
+ const isActive = option === value;
+ return (
+ onChange(option)}
+ style={{
+ flex: 1,
+ borderRadius: 8,
+ borderWidth: 1,
+ borderColor: isActive ? colors.accent : colors.border,
+ backgroundColor: isActive ? `${colors.accent}22` : '#0D1117',
+ paddingVertical: 10,
+ alignItems: 'center',
+ }}
+ >
+
+ {option.toUpperCase()}
+
+
+ );
+ })}
+
+ );
+};
diff --git a/mobile/src/screens/SettingsScreen/index.tsx b/mobile/src/screens/SettingsScreen/index.tsx
index e69de29..2bfc475 100644
--- a/mobile/src/screens/SettingsScreen/index.tsx
+++ b/mobile/src/screens/SettingsScreen/index.tsx
@@ -0,0 +1,169 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Linking, ScrollView, View } from 'react-native';
+import { ThemedText } from '@/components/ThemedText';
+import { ThemedView } from '@/components/ThemedView';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+import { WS_PATH } from '@/constants/websocket';
+import { apiService } from '@/services/api.service';
+import { wsService } from '@/services/ws.service';
+import { useSettingsStore } from '@/stores/settingsStore';
+import { Alert, Pressable, Platform } from 'react-native';
+import { ThemePicker } from './ThemePicker';
+import { RssiToggle } from './RssiToggle';
+import { ServerUrlInput } from './ServerUrlInput';
+
+type GlowCardProps = {
+ title: string;
+ children: React.ReactNode;
+};
+
+const GlowCard = ({ title, children }: GlowCardProps) => {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+};
+
+const ScanIntervalPicker = ({
+ value,
+ onChange,
+}: {
+ value: number;
+ onChange: (value: number) => void;
+}) => {
+ const options = [1, 2, 5];
+
+ return (
+
+ {options.map((interval) => {
+ const isActive = interval === value;
+ return (
+ onChange(interval)}
+ style={{
+ flex: 1,
+ borderWidth: 1,
+ borderColor: isActive ? colors.accent : colors.border,
+ borderRadius: 8,
+ backgroundColor: isActive ? `${colors.accent}20` : colors.surface,
+ alignItems: 'center',
+ }}
+ >
+
+ {interval}s
+
+
+ );
+ })}
+
+ );
+};
+
+export const SettingsScreen = () => {
+ const serverUrl = useSettingsStore((state) => state.serverUrl);
+ const rssiScanEnabled = useSettingsStore((state) => state.rssiScanEnabled);
+ const theme = useSettingsStore((state) => state.theme);
+ const setServerUrl = useSettingsStore((state) => state.setServerUrl);
+ const setRssiScanEnabled = useSettingsStore((state) => state.setRssiScanEnabled);
+ const setTheme = useSettingsStore((state) => state.setTheme);
+
+ const [draftUrl, setDraftUrl] = useState(serverUrl);
+ const [scanInterval, setScanInterval] = useState(2);
+
+ useEffect(() => {
+ setDraftUrl(serverUrl);
+ }, [serverUrl]);
+
+ const intervalSummary = useMemo(() => `${scanInterval}s`, [scanInterval]);
+
+ const handleSaveUrl = () => {
+ setServerUrl(draftUrl);
+ apiService.setBaseUrl(draftUrl);
+ wsService.disconnect();
+ wsService.connect(draftUrl);
+ };
+
+ const handleOpenGitHub = async () => {
+ const handled = await Linking.canOpenURL('https://github.com');
+ if (!handled) {
+ Alert.alert('Unable to open link', 'Please open https://github.com manually in your browser.');
+ return;
+ }
+
+ await Linking.openURL('https://github.com');
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ Scan interval
+
+
+
+ Active interval: {intervalSummary}
+
+ {Platform.OS === 'ios' && (
+
+ iOS: RSSI scanning uses stubbed telemetry in this build.
+
+ )}
+
+
+
+
+
+
+
+
+ WiFi-DensePose Mobile v1.0.0
+
+
+ View on GitHub
+
+ WebSocket: {WS_PATH}
+
+ Triage priority mapping: Immediate/Delayed/Minor/Deceased/Unknown
+
+
+
+
+ );
+};
+
+export default SettingsScreen;
diff --git a/mobile/src/screens/ZonesScreen/FloorPlanSvg.tsx b/mobile/src/screens/ZonesScreen/FloorPlanSvg.tsx
index e69de29..43a9728 100644
--- a/mobile/src/screens/ZonesScreen/FloorPlanSvg.tsx
+++ b/mobile/src/screens/ZonesScreen/FloorPlanSvg.tsx
@@ -0,0 +1,201 @@
+import { useEffect, useMemo } from 'react';
+import { View, ViewStyle } from 'react-native';
+import Svg, { Circle, Polygon, Rect } from 'react-native-svg';
+import Animated, {
+ createAnimatedComponent,
+ useAnimatedProps,
+ useAnimatedStyle,
+ useDerivedValue,
+ useSharedValue,
+ withTiming,
+ type SharedValue,
+} from 'react-native-reanimated';
+import {
+ Gesture,
+ GestureDetector,
+} from 'react-native-gesture-handler';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+import { valueToColor } from '@/utils/colorMap';
+
+const GRID_SIZE = 20;
+const CELL_COUNT = GRID_SIZE * GRID_SIZE;
+
+type Point = {
+ x: number;
+ y: number;
+};
+
+type FloorPlanSvgProps = {
+ gridValues: number[];
+ personPositions: Point[];
+ size?: number;
+ style?: ViewStyle;
+};
+
+const clamp01 = (value: number) => Math.max(0, Math.min(1, value));
+
+const colorToRgba = (value: number): string => {
+ const [r, g, b] = valueToColor(clamp01(value));
+ return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 1)`;
+};
+
+const normalizeGrid = (values: number[]): number[] => {
+ const normalized = new Array(CELL_COUNT).fill(0);
+ const sourceLength = Math.min(values.length, CELL_COUNT);
+
+ for (let i = 0; i < sourceLength; i += 1) {
+ const raw = values?.[i];
+ normalized[i] = clamp01(typeof raw === 'number' && Number.isFinite(raw) ? raw : 0);
+ }
+
+ return normalized;
+};
+
+const AnimatedRect = createAnimatedComponent(Rect);
+
+const AnimatedContainer = Animated.View;
+
+const Cell = ({
+ index,
+ size,
+ values,
+ progress,
+}: {
+ index: number;
+ size: number;
+ values: SharedValue;
+ progress: SharedValue;
+}) => {
+ const cellSize = size / GRID_SIZE;
+ const x = (index % GRID_SIZE) * cellSize;
+ const y = Math.floor(index / GRID_SIZE) * cellSize;
+
+ const animatedProps = useAnimatedProps(() => {
+ const fill = colorToRgba(values.value[index] ?? 0);
+ return {
+ fill,
+ opacity: 0.95 + (progress.value - 1) * 0.05,
+ };
+ }, [index]);
+
+ return ;
+};
+
+const RouterMarker = ({ cellSize }: { cellSize: number }) => {
+ const cx = cellSize * 5.5;
+ const cy = cellSize * 17.5;
+ const radius = cellSize * 0.35;
+
+ return (
+
+ );
+};
+
+export const FloorPlanSvg = ({ gridValues, personPositions, size = 320, style }: FloorPlanSvgProps) => {
+ const normalizedValues = useMemo(() => normalizeGrid(gridValues), [gridValues]);
+
+ const values = useSharedValue(normalizedValues);
+ const previousValues = useSharedValue(normalizedValues);
+ const targetValues = useSharedValue(normalizedValues);
+ const progress = useSharedValue(1);
+
+ const translateX = useSharedValue(0);
+ const translateY = useSharedValue(0);
+ const panStartX = useSharedValue(0);
+ const panStartY = useSharedValue(0);
+
+ const panGesture = Gesture.Pan()
+ .onStart(() => {
+ panStartX.value = translateX.value;
+ panStartY.value = translateY.value;
+ })
+ .onUpdate((event) => {
+ translateX.value = panStartX.value + event.translationX;
+ translateY.value = panStartY.value + event.translationY;
+ })
+ .onEnd(() => {
+ panStartX.value = translateX.value;
+ panStartY.value = translateY.value;
+ });
+
+ const panStyle = useAnimatedStyle(() => ({
+ transform: [
+ { translateX: translateX.value },
+ { translateY: translateY.value },
+ ],
+ }));
+
+ useDerivedValue(() => {
+ const interpolated = new Array(CELL_COUNT).fill(0);
+ const from = previousValues.value;
+ const to = targetValues.value;
+ const p = progress.value;
+
+ for (let i = 0; i < CELL_COUNT; i += 1) {
+ const start = from[i] ?? 0;
+ const end = to[i] ?? 0;
+ interpolated[i] = start + (end - start) * p;
+ }
+ values.value = interpolated;
+ });
+
+ useEffect(() => {
+ const next = normalizeGrid(normalizedValues);
+ previousValues.value = values.value;
+ targetValues.value = next;
+ progress.value = 0;
+ progress.value = withTiming(1, { duration: 500 });
+ }, [normalizedValues, previousValues, targetValues, progress, values]);
+
+ const markers = useMemo(() => {
+ const cellSize = size / GRID_SIZE;
+ return personPositions
+ .map((point, idx) => {
+ const cx = (Math.max(0, Math.min(GRID_SIZE - 1, point.x)) + 0.5) * cellSize;
+ const cy = (Math.max(0, Math.min(GRID_SIZE - 1, point.y)) + 0.5) * cellSize;
+ const radius = Math.max(2.8, cellSize * 0.22);
+
+ return (
+
+ );
+ })
+ .concat(
+ ,
+ );
+ }, [personPositions, size]);
+
+ return (
+
+
+
+
+
+
+
+ );
+};
diff --git a/mobile/src/screens/ZonesScreen/ZoneLegend.tsx b/mobile/src/screens/ZonesScreen/ZoneLegend.tsx
index e69de29..deceede 100644
--- a/mobile/src/screens/ZonesScreen/ZoneLegend.tsx
+++ b/mobile/src/screens/ZonesScreen/ZoneLegend.tsx
@@ -0,0 +1,54 @@
+import { View } from 'react-native';
+import { ThemedText } from '@/components/ThemedText';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+import { valueToColor } from '@/utils/colorMap';
+
+type LegendStop = {
+ label: string;
+ color: string;
+};
+
+const LEGEND_STOPS: LegendStop[] = [
+ { label: 'Quiet', color: colorToRgba(0) },
+ { label: 'Low', color: colorToRgba(0.25) },
+ { label: 'Medium', color: colorToRgba(0.5) },
+ { label: 'High', color: colorToRgba(0.75) },
+ { label: 'Active', color: colorToRgba(1) },
+];
+
+function colorToRgba(value: number): string {
+ const [r, g, b] = valueToColor(value);
+ return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 1)`;
+}
+
+export const ZoneLegend = () => {
+ return (
+
+ {LEGEND_STOPS.map((stop) => (
+
+
+
+ {stop.label}
+
+
+ ))}
+
+ );
+};
diff --git a/mobile/src/screens/ZonesScreen/index.tsx b/mobile/src/screens/ZonesScreen/index.tsx
index e69de29..9565d1e 100644
--- a/mobile/src/screens/ZonesScreen/index.tsx
+++ b/mobile/src/screens/ZonesScreen/index.tsx
@@ -0,0 +1,82 @@
+import { useMemo } from 'react';
+import { ScrollView, useWindowDimensions, View } from 'react-native';
+import { ConnectionBanner } from '@/components/ConnectionBanner';
+import { ThemedText } from '@/components/ThemedText';
+import { ThemedView } from '@/components/ThemedView';
+import { colors } from '@/theme/colors';
+import { spacing } from '@/theme/spacing';
+import { usePoseStore } from '@/stores/poseStore';
+import { type ConnectionStatus } from '@/types/sensing';
+import { useOccupancyGrid } from './useOccupancyGrid';
+import { FloorPlanSvg } from './FloorPlanSvg';
+import { ZoneLegend } from './ZoneLegend';
+
+const getLastUpdateSeconds = (timestamp?: number): string => {
+ if (!timestamp) {
+ return 'N/A';
+ }
+
+ const ageMs = Date.now() - timestamp;
+ const secs = Math.max(0, ageMs / 1000);
+ return `${secs.toFixed(1)}s`;
+};
+
+const resolveBannerState = (status: ConnectionStatus): 'connected' | 'simulated' | 'disconnected' => {
+ if (status === 'connecting') {
+ return 'disconnected';
+ }
+
+ return status;
+};
+
+export const ZonesScreen = () => {
+ const connectionStatus = usePoseStore((state) => state.connectionStatus);
+ const lastFrame = usePoseStore((state) => state.lastFrame);
+ const signalField = usePoseStore((state) => state.signalField);
+
+ const { gridValues, personPositions } = useOccupancyGrid(signalField);
+
+ const { width } = useWindowDimensions();
+ const mapSize = useMemo(() => Math.max(240, Math.min(width - spacing.md * 2, 520)), [width]);
+
+ return (
+
+
+
+
+
+ Floor Plan — Occupancy Heatmap
+
+
+
+
+
+
+
+
+ Occupancy: {personPositions.length} persons detected
+ Last update: {getLastUpdateSeconds(lastFrame?.timestamp)}
+
+
+
+ );
+};
+
+export default ZonesScreen;
diff --git a/mobile/src/screens/ZonesScreen/useOccupancyGrid.ts b/mobile/src/screens/ZonesScreen/useOccupancyGrid.ts
index e69de29..4601bdd 100644
--- a/mobile/src/screens/ZonesScreen/useOccupancyGrid.ts
+++ b/mobile/src/screens/ZonesScreen/useOccupancyGrid.ts
@@ -0,0 +1,109 @@
+import { useMemo } from 'react';
+import type { Classification, SignalField } from '@/types/sensing';
+import { usePoseStore } from '@/stores/poseStore';
+
+const GRID_SIZE = 20;
+const CELL_COUNT = GRID_SIZE * GRID_SIZE;
+
+type Point = {
+ x: number;
+ y: number;
+};
+
+const clamp01 = (value: number): number => {
+ if (Number.isNaN(value)) {
+ return 0;
+ }
+
+ return Math.max(0, Math.min(1, value));
+};
+
+const parseNumber = (value: unknown): number | null => {
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
+};
+
+const parsePoint = (value: unknown): Point | null => {
+ if (!value || typeof value !== 'object') {
+ return null;
+ }
+
+ const record = value as Record;
+ const x = parseNumber(record.x);
+ const y = parseNumber(record.y);
+
+ if (x === null || y === null) {
+ return null;
+ }
+
+ return {
+ x,
+ y,
+ };
+};
+
+const collectPositions = (value: unknown): Point[] => {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+
+ return value
+ .map((entry) => parsePoint(entry))
+ .filter((point): point is Point => point !== null)
+ .map((point) => ({
+ x: point.x,
+ y: point.y,
+ }));
+};
+
+const readClassificationPositions = (classification: Classification | undefined): Point[] => {
+ const source = classification as unknown as Record;
+
+ return (
+ collectPositions(source?.persons) ??
+ collectPositions(source?.personPositions) ??
+ collectPositions(source?.positions) ??
+ []
+ );
+};
+
+export const useOccupancyGrid = (signalField: SignalField | null): { gridValues: number[]; personPositions: Point[] } => {
+ const classification = usePoseStore((state) => state.classification) as Classification | undefined;
+
+ const gridValues = useMemo(() => {
+ const sourceValues = signalField?.values;
+
+ if (!sourceValues || sourceValues.length === 0) {
+ return new Array(CELL_COUNT).fill(0);
+ }
+
+ const normalized = new Array(CELL_COUNT).fill(0);
+ const sourceLength = Math.min(CELL_COUNT, sourceValues.length);
+
+ for (let i = 0; i < sourceLength; i += 1) {
+ const value = parseNumber(sourceValues[i]);
+ normalized[i] = clamp01(value ?? 0);
+ }
+
+ return normalized;
+ }, [signalField?.values]);
+
+ const personPositions = useMemo(() => {
+ const positions = readClassificationPositions(classification);
+
+ if (positions.length > 0) {
+ return positions
+ .map(({ x, y }) => ({
+ x: Math.max(0, Math.min(GRID_SIZE - 1, x)),
+ y: Math.max(0, Math.min(GRID_SIZE - 1, y)),
+ }))
+ .slice(0, 16);
+ }
+
+ return [] as Point[];
+ }, [classification]);
+
+ return {
+ gridValues,
+ personPositions,
+ };
+};