From a47c4363b7ab4a5e70f37398f3272ea5c36a7f11 Mon Sep 17 00:00:00 2001 From: Yossi Elkrief Date: Mon, 2 Mar 2026 12:58:47 +0200 Subject: [PATCH] feat: implement zones mat and settings screens --- mobile/src/assets/webview/mat-dashboard.html | 505 ++++++++++++++++++ mobile/src/screens/MATScreen/AlertCard.tsx | 84 +++ mobile/src/screens/MATScreen/AlertList.tsx | 41 ++ mobile/src/screens/MATScreen/MatWebView.tsx | 26 + .../src/screens/MATScreen/SurvivorCounter.tsx | 89 +++ mobile/src/screens/MATScreen/index.tsx | 138 +++++ mobile/src/screens/MATScreen/useMatBridge.ts | 118 ++++ .../src/screens/SettingsScreen/RssiToggle.tsx | 36 ++ .../screens/SettingsScreen/ServerUrlInput.tsx | 102 ++++ .../screens/SettingsScreen/ThemePicker.tsx | 47 ++ mobile/src/screens/SettingsScreen/index.tsx | 169 ++++++ .../src/screens/ZonesScreen/FloorPlanSvg.tsx | 201 +++++++ mobile/src/screens/ZonesScreen/ZoneLegend.tsx | 54 ++ mobile/src/screens/ZonesScreen/index.tsx | 82 +++ .../screens/ZonesScreen/useOccupancyGrid.ts | 109 ++++ 15 files changed, 1801 insertions(+) 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 ( + + + + + {Array.from({ length: CELL_COUNT }).map((_, index) => ( + + ))} + {markers} + + + + + ); +}; 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, + }; +};