feat: Zones, MAT, and Settings screens #82
1
mobile/.env.example
Normal file
1
mobile/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
EXPO_PUBLIC_DEFAULT_SERVER_URL=http://192.168.1.100:8080
|
||||||
26
mobile/.eslintrc.js
Normal file
26
mobile/.eslintrc.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:react/recommended',
|
||||||
|
'plugin:react-hooks/recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
],
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: 'detect',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'react/react-in-jsx-scope': 'off',
|
||||||
|
},
|
||||||
|
};
|
||||||
41
mobile/.gitignore
vendored
Normal file
41
mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
4
mobile/.prettierrc
Normal file
4
mobile/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
38
mobile/App.tsx
Normal file
38
mobile/App.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { NavigationContainer, DarkTheme } from '@react-navigation/native';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
import { ThemeProvider } from './src/theme/ThemeContext';
|
||||||
|
import { RootNavigator } from './src/navigation/RootNavigator';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
useEffect(() => {
|
||||||
|
(globalThis as { __appStartTime?: number }).__appStartTime = Date.now();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const navigationTheme = {
|
||||||
|
...DarkTheme,
|
||||||
|
colors: {
|
||||||
|
...DarkTheme.colors,
|
||||||
|
background: '#0A0E1A',
|
||||||
|
card: '#0D1117',
|
||||||
|
text: '#E2E8F0',
|
||||||
|
border: '#1E293B',
|
||||||
|
primary: '#32B8C6',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
<NavigationContainer theme={navigationTheme}>
|
||||||
|
<RootNavigator />
|
||||||
|
</NavigationContainer>
|
||||||
|
</ThemeProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
<StatusBar style="light" />
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
mobile/app.config.ts
Normal file
12
mobile/app.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export default {
|
||||||
|
name: 'WiFi-DensePose',
|
||||||
|
slug: 'wifi-densepose',
|
||||||
|
version: '1.0.0',
|
||||||
|
ios: {
|
||||||
|
bundleIdentifier: 'com.ruvnet.wifidensepose',
|
||||||
|
},
|
||||||
|
android: {
|
||||||
|
package: 'com.ruvnet.wifidensepose',
|
||||||
|
},
|
||||||
|
// Use expo-env and app-level defaults from the project configuration when available.
|
||||||
|
};
|
||||||
30
mobile/app.json
Normal file
30
mobile/app.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "mobile",
|
||||||
|
"slug": "mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "light",
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/splash-icon.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"backgroundColor": "#E6F4FE",
|
||||||
|
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||||
|
"backgroundImage": "./assets/android-icon-background.png",
|
||||||
|
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||||
|
},
|
||||||
|
"predictiveBackGestureEnabled": false
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"favicon": "./assets/favicon.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
mobile/assets/android-icon-background.png
Normal file
BIN
mobile/assets/android-icon-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
mobile/assets/android-icon-foreground.png
Normal file
BIN
mobile/assets/android-icon-foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
BIN
mobile/assets/android-icon-monochrome.png
Normal file
BIN
mobile/assets/android-icon-monochrome.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
BIN
mobile/assets/favicon.png
Normal file
BIN
mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
mobile/assets/icon.png
Normal file
BIN
mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
BIN
mobile/assets/splash-icon.png
Normal file
BIN
mobile/assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
9
mobile/babel.config.js
Normal file
9
mobile/babel.config.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
plugins: [
|
||||||
|
'react-native-reanimated/plugin'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
0
mobile/e2e/.maestro/config.yaml
Normal file
0
mobile/e2e/.maestro/config.yaml
Normal file
0
mobile/e2e/live_screen.yaml
Normal file
0
mobile/e2e/live_screen.yaml
Normal file
0
mobile/e2e/mat_screen.yaml
Normal file
0
mobile/e2e/mat_screen.yaml
Normal file
0
mobile/e2e/offline_fallback.yaml
Normal file
0
mobile/e2e/offline_fallback.yaml
Normal file
0
mobile/e2e/settings_screen.yaml
Normal file
0
mobile/e2e/settings_screen.yaml
Normal file
0
mobile/e2e/vitals_screen.yaml
Normal file
0
mobile/e2e/vitals_screen.yaml
Normal file
0
mobile/e2e/zones_screen.yaml
Normal file
0
mobile/e2e/zones_screen.yaml
Normal file
17
mobile/eas.json
Normal file
17
mobile/eas.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 4.0.0"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
mobile/index.ts
Normal file
4
mobile/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { registerRootComponent } from 'expo';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
registerRootComponent(App);
|
||||||
8
mobile/jest.config.js
Normal file
8
mobile/jest.config.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: 'jest-expo',
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||||
|
testPathIgnorePatterns: ['/src/__tests__/'],
|
||||||
|
transformIgnorePatterns: [
|
||||||
|
'node_modules/(?!(expo|expo-.+|react-native|@react-native|react-native-webview|react-native-reanimated|react-native-svg|react-native-safe-area-context|react-native-screens|@react-navigation|@expo|@unimodules|expo-modules-core)/)',
|
||||||
|
],
|
||||||
|
};
|
||||||
11
mobile/jest.setup.ts
Normal file
11
mobile/jest.setup.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
jest.mock('@react-native-async-storage/async-storage', () =>
|
||||||
|
require('@react-native-async-storage/async-storage/jest/async-storage-mock')
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.mock('react-native-wifi-reborn', () => ({
|
||||||
|
loadWifiList: jest.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('react-native-reanimated', () =>
|
||||||
|
require('react-native-reanimated/mock')
|
||||||
|
);
|
||||||
16327
mobile/package-lock.json
generated
Normal file
16327
mobile/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
mobile/package.json
Normal file
49
mobile/package.json
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"test": "jest",
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^15.0.2",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.15.3",
|
||||||
|
"@react-navigation/native": "^7.1.31",
|
||||||
|
"axios": "^1.13.6",
|
||||||
|
"expo": "~55.0.4",
|
||||||
|
"expo-status-bar": "~55.0.4",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-native": "0.83.2",
|
||||||
|
"react-native-gesture-handler": "~2.30.0",
|
||||||
|
"react-native-reanimated": "4.2.1",
|
||||||
|
"react-native-safe-area-context": "~5.6.2",
|
||||||
|
"react-native-screens": "~4.23.0",
|
||||||
|
"react-native-svg": "15.15.3",
|
||||||
|
"react-native-webview": "13.16.0",
|
||||||
|
"react-native-wifi-reborn": "^4.13.6",
|
||||||
|
"victory-native": "^41.20.2",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-native": "^5.4.3",
|
||||||
|
"@testing-library/react-native": "^13.3.3",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/react": "~19.2.2",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||||
|
"@typescript-eslint/parser": "^8.56.1",
|
||||||
|
"babel-preset-expo": "^55.0.10",
|
||||||
|
"eslint": "^10.0.2",
|
||||||
|
"jest": "^30.2.0",
|
||||||
|
"jest-expo": "^55.0.9",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"react-native-worklets": "^0.7.4",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/components/GaugeArc.test.tsx
Normal file
5
mobile/src/__tests__/components/GaugeArc.test.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/components/HudOverlay.test.tsx
Normal file
5
mobile/src/__tests__/components/HudOverlay.test.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/components/OccupancyGrid.test.tsx
Normal file
5
mobile/src/__tests__/components/OccupancyGrid.test.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/components/SignalBar.test.tsx
Normal file
5
mobile/src/__tests__/components/SignalBar.test.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/components/SparklineChart.test.tsx
Normal file
5
mobile/src/__tests__/components/SparklineChart.test.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/components/StatusDot.test.tsx
Normal file
5
mobile/src/__tests__/components/StatusDot.test.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/hooks/usePoseStream.test.ts
Normal file
5
mobile/src/__tests__/hooks/usePoseStream.test.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/hooks/useRssiScanner.test.ts
Normal file
5
mobile/src/__tests__/hooks/useRssiScanner.test.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/hooks/useServerReachability.test.ts
Normal file
5
mobile/src/__tests__/hooks/useServerReachability.test.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/screens/LiveScreen.test.tsx
Normal file
5
mobile/src/__tests__/screens/LiveScreen.test.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/screens/MATScreen.test.tsx
Normal file
5
mobile/src/__tests__/screens/MATScreen.test.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/screens/SettingsScreen.test.tsx
Normal file
5
mobile/src/__tests__/screens/SettingsScreen.test.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/screens/VitalsScreen.test.tsx
Normal file
5
mobile/src/__tests__/screens/VitalsScreen.test.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/screens/ZonesScreen.test.tsx
Normal file
5
mobile/src/__tests__/screens/ZonesScreen.test.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/services/api.service.test.ts
Normal file
5
mobile/src/__tests__/services/api.service.test.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/services/rssi.service.test.ts
Normal file
5
mobile/src/__tests__/services/rssi.service.test.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/services/simulation.service.test.ts
Normal file
5
mobile/src/__tests__/services/simulation.service.test.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/services/ws.service.test.ts
Normal file
5
mobile/src/__tests__/services/ws.service.test.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/stores/matStore.test.ts
Normal file
5
mobile/src/__tests__/stores/matStore.test.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/stores/poseStore.test.ts
Normal file
5
mobile/src/__tests__/stores/poseStore.test.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/stores/settingsStore.test.ts
Normal file
5
mobile/src/__tests__/stores/settingsStore.test.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/utils/colorMap.test.ts
Normal file
5
mobile/src/__tests__/utils/colorMap.test.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/utils/ringBuffer.test.ts
Normal file
5
mobile/src/__tests__/utils/ringBuffer.test.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
mobile/src/__tests__/utils/urlValidator.test.ts
Normal file
5
mobile/src/__tests__/utils/urlValidator.test.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
describe('placeholder', () => {
|
||||||
|
it('passes', () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
0
mobile/src/assets/images/wifi-icon.png
Normal file
0
mobile/src/assets/images/wifi-icon.png
Normal file
0
mobile/src/assets/webview/gaussian-splats.html
Normal file
0
mobile/src/assets/webview/gaussian-splats.html
Normal file
505
mobile/src/assets/webview/mat-dashboard.html
Normal file
505
mobile/src/assets/webview/mat-dashboard.html
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>MAT Dashboard</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #0a0e1a;
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-family: 'Courier New', 'Consolas', monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status {
|
||||||
|
color: #6dd4df;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mapCanvas {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-height: 180px;
|
||||||
|
background: #0a0e1a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div id="status">Initializing MAT dashboard...</div>
|
||||||
|
<canvas id="mapCanvas"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const TRIAGE = {
|
||||||
|
Immediate: 0,
|
||||||
|
Delayed: 1,
|
||||||
|
Minimal: 2,
|
||||||
|
Expectant: 3,
|
||||||
|
Unknown: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRIAGE_COLOR = ['#ff0000', '#ffcc00', '#00cc00', '#111111', '#888888'];
|
||||||
|
const PRIORITY = { Critical: 0, High: 1, Medium: 2, Low: 3 };
|
||||||
|
|
||||||
|
const toRgba = (status) => TRIAGE_COLOR[status] || TRIAGE_COLOR[4];
|
||||||
|
const safeId = () =>
|
||||||
|
typeof crypto !== 'undefined' && crypto.randomUUID
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `id-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||||
|
|
||||||
|
const isNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
||||||
|
|
||||||
|
class MatDashboard {
|
||||||
|
constructor() {
|
||||||
|
this.event = null;
|
||||||
|
this.zones = new Map();
|
||||||
|
this.survivors = new Map();
|
||||||
|
this.alerts = new Map();
|
||||||
|
this.motionVector = { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
createEvent(type, lat, lon, name) {
|
||||||
|
const eventId = safeId();
|
||||||
|
this.event = {
|
||||||
|
event_id: eventId,
|
||||||
|
disaster_type: type,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
description: name,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
this.zones.clear();
|
||||||
|
this.survivors.clear();
|
||||||
|
this.alerts.clear();
|
||||||
|
return eventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
addRectangleZone(name, x, y, w, h) {
|
||||||
|
const id = safeId();
|
||||||
|
this.zones.set(id, {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
zone_type: 'rectangle',
|
||||||
|
status: 0,
|
||||||
|
scan_count: 0,
|
||||||
|
detection_count: 0,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
addCircleZone(name, cx, cy, radius) {
|
||||||
|
const id = safeId();
|
||||||
|
this.zones.set(id, {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
zone_type: 'circle',
|
||||||
|
status: 0,
|
||||||
|
scan_count: 0,
|
||||||
|
detection_count: 0,
|
||||||
|
center_x: cx,
|
||||||
|
center_y: cy,
|
||||||
|
radius,
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
addZoneFromPayload(payload) {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = payload;
|
||||||
|
const type = source.zone_type || source.type || 'rectangle';
|
||||||
|
const name = source.name || `Zone-${safeId().slice(0, 4)}`;
|
||||||
|
|
||||||
|
if (type === 'circle' || source.center_x !== undefined) {
|
||||||
|
const cx = isNumber(source.center_x) ? source.center_x : 120;
|
||||||
|
const cy = isNumber(source.center_y) ? source.center_y : 120;
|
||||||
|
const radius = isNumber(source.radius) ? source.radius : 50;
|
||||||
|
return this.addCircleZone(name, cx, cy, radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = isNumber(source.x) ? source.x : 40;
|
||||||
|
const y = isNumber(source.y) ? source.y : 40;
|
||||||
|
const width = isNumber(source.width) ? source.width : 100;
|
||||||
|
const height = isNumber(source.height) ? source.height : 100;
|
||||||
|
return this.addRectangleZone(name, x, y, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
inferTriage(vitalSigns, confidence) {
|
||||||
|
const breathing = isNumber(vitalSigns?.breathing_rate) ? vitalSigns.breathing_rate : 14;
|
||||||
|
const heart = isNumber(vitalSigns?.heart_rate)
|
||||||
|
? vitalSigns.heart_rate
|
||||||
|
: isNumber(vitalSigns?.hr)
|
||||||
|
? vitalSigns.hr
|
||||||
|
: 70;
|
||||||
|
|
||||||
|
if (!isNumber(confidence) || confidence > 0.82) {
|
||||||
|
if (breathing < 10 || breathing > 35 || heart > 150) {
|
||||||
|
return TRIAGE.Immediate;
|
||||||
|
}
|
||||||
|
if (breathing >= 8 && breathing <= 34) {
|
||||||
|
return TRIAGE.Delayed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (breathing >= 6 && breathing <= 28 && heart > 45 && heart < 180) {
|
||||||
|
return TRIAGE.Minimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TRIAGE.Expectant;
|
||||||
|
}
|
||||||
|
|
||||||
|
locateZoneForPoint(x, y) {
|
||||||
|
for (const [id, zone] of this.zones.entries()) {
|
||||||
|
if (zone.zone_type === 'circle') {
|
||||||
|
const dx = x - zone.center_x;
|
||||||
|
const dy = y - zone.center_y;
|
||||||
|
const inside = Math.sqrt(dx * dx + dy * dy) <= zone.radius;
|
||||||
|
if (inside) {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (x >= zone.x && x <= zone.x + zone.width && y >= zone.y && y <= zone.y + zone.height) {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.zones.size > 0 ? this.zones.keys().next().value : safeId();
|
||||||
|
}
|
||||||
|
|
||||||
|
processSurvivorDetection(zone, confidence = 0.6, vital_signs = {}) {
|
||||||
|
const zoneKey =
|
||||||
|
typeof zone === 'string'
|
||||||
|
? [...this.zones.values()].find((entry) => entry.id === zone || entry.name === zone)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const selectedZone =
|
||||||
|
zoneKey
|
||||||
|
|| (this.zones.size > 0
|
||||||
|
? [...this.zones.values()][Math.floor(Math.random() * Math.max(1, this.zones.size))]
|
||||||
|
: null);
|
||||||
|
|
||||||
|
const bounds = this._pickPointInZone(selectedZone);
|
||||||
|
const triageStatus = this.inferTriage(vital_signs, confidence);
|
||||||
|
const breathingRate = isNumber(vital_signs?.breathing_rate)
|
||||||
|
? vital_signs.breathing_rate
|
||||||
|
: 10 + confidence * 28;
|
||||||
|
const heartRate = isNumber(vital_signs?.heart_rate)
|
||||||
|
? vital_signs.heart_rate
|
||||||
|
: isNumber(vital_signs?.hr)
|
||||||
|
? vital_signs.hr
|
||||||
|
: 55 + confidence * 60;
|
||||||
|
|
||||||
|
const id = safeId();
|
||||||
|
const zone_id = this.locateZoneForPoint(bounds.x, bounds.y);
|
||||||
|
|
||||||
|
const survivor = {
|
||||||
|
id,
|
||||||
|
zone_id,
|
||||||
|
x: bounds.x,
|
||||||
|
y: bounds.y,
|
||||||
|
depth: -Math.abs(isNumber(vital_signs.depth) ? vital_signs.depth : Math.random() * 3),
|
||||||
|
triage_status: triageStatus,
|
||||||
|
triage_color: toRgba(triageStatus),
|
||||||
|
confidence,
|
||||||
|
breathing_rate: breathingRate,
|
||||||
|
heart_rate: heartRate,
|
||||||
|
first_detected: new Date().toISOString(),
|
||||||
|
last_updated: new Date().toISOString(),
|
||||||
|
is_deteriorating: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.survivors.set(id, survivor);
|
||||||
|
if (selectedZone) {
|
||||||
|
selectedZone.detection_count = (selectedZone.detection_count || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof this.postMessage === 'function') {
|
||||||
|
this.postMessage({
|
||||||
|
type: 'SURVIVOR_DETECTED',
|
||||||
|
payload: survivor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.generateAlerts();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pickPointInZone(zone) {
|
||||||
|
if (!zone) {
|
||||||
|
return {
|
||||||
|
x: 220 + Math.random() * 80,
|
||||||
|
y: 120 + Math.random() * 80,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zone.zone_type === 'circle') {
|
||||||
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
const radius = Math.random() * (zone.radius || 20);
|
||||||
|
return {
|
||||||
|
x: Math.max(10, Math.min(560, zone.center_x + Math.cos(angle) * radius)),
|
||||||
|
y: Math.max(10, Math.min(280, zone.center_y + Math.sin(angle) * radius)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.max(zone.x || 5, Math.min((zone.x || 5) + (zone.width || 40), (zone.x || 5) + Math.random() * (zone.width || 40))),
|
||||||
|
y: Math.max(zone.y || 5, Math.min((zone.y || 5) + (zone.height || 40), (zone.y || 5) + Math.random() * (zone.height || 40))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
generateAlerts() {
|
||||||
|
for (const survivor of this.survivors.values()) {
|
||||||
|
if ((survivor.triage_status !== TRIAGE.Immediate && survivor.triage_status !== TRIAGE.Delayed)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertId = `alert-${survivor.id}`;
|
||||||
|
if (this.alerts.has(alertId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priority =
|
||||||
|
survivor.triage_status === TRIAGE.Immediate ? PRIORITY.Critical : PRIORITY.High;
|
||||||
|
const message =
|
||||||
|
survivor.triage_status === TRIAGE.Immediate
|
||||||
|
? `Immediate rescue required at (${survivor.x.toFixed(0)}, ${survivor.y.toFixed(0)})`
|
||||||
|
: `High-priority rescue needed at (${survivor.x.toFixed(0)}, ${survivor.y.toFixed(0)})`;
|
||||||
|
const alert = {
|
||||||
|
id: alertId,
|
||||||
|
survivor_id: survivor.id,
|
||||||
|
priority,
|
||||||
|
title: survivor.triage_status === TRIAGE.Immediate ? 'URGENT' : 'HIGH',
|
||||||
|
message,
|
||||||
|
recommended_action: survivor.triage_status === TRIAGE.Immediate ? 'Dispatch now' : 'Coordinate rescue',
|
||||||
|
triage_status: survivor.triage_status,
|
||||||
|
location_x: survivor.x,
|
||||||
|
location_y: survivor.y,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
priority_color: survivor.triage_status === TRIAGE.Immediate ? '#ff0000' : '#ff8c00',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.alerts.set(alertId, alert);
|
||||||
|
if (typeof this.postMessage === 'function') {
|
||||||
|
this.postMessage({
|
||||||
|
type: 'ALERT_GENERATED',
|
||||||
|
payload: alert,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processFrame(frame) {
|
||||||
|
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)
|
||||||
|
? (frame.features.breathing_band_power - 0.1) * 3
|
||||||
|
: 0;
|
||||||
|
this.motionVector = { x: xDelta || 0, y: yDelta || 0 };
|
||||||
|
|
||||||
|
for (const survivor of this.survivors.values()) {
|
||||||
|
const jitterX = (Math.random() - 0.5) * 2;
|
||||||
|
const jitterY = (Math.random() - 0.5) * 2;
|
||||||
|
survivor.x = Math.max(5, Math.min(560, survivor.x + this.motionVector.x + jitterX));
|
||||||
|
survivor.y = Math.max(5, Math.min(280, survivor.y + this.motionVector.y + jitterY));
|
||||||
|
survivor.last_updated = new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderZones(ctx) {
|
||||||
|
for (const zone of this.zones.values()) {
|
||||||
|
const fill = 'rgba(0, 150, 255, 0.3)';
|
||||||
|
ctx.strokeStyle = '#0096ff';
|
||||||
|
ctx.fillStyle = fill;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
|
||||||
|
if (zone.zone_type === 'circle') {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(zone.center_x, zone.center_y, zone.radius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = '12px monospace';
|
||||||
|
ctx.fillText(zone.name, zone.center_x - 22, zone.center_y);
|
||||||
|
} else {
|
||||||
|
ctx.fillRect(zone.x, zone.y, zone.width, zone.height);
|
||||||
|
ctx.strokeRect(zone.x, zone.y, zone.width, zone.height);
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = '12px monospace';
|
||||||
|
ctx.fillText(zone.name, zone.x + 4, zone.y + 14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSurvivors(ctx) {
|
||||||
|
for (const survivor of this.survivors.values()) {
|
||||||
|
const radius = survivor.is_deteriorating ? 11 : 9;
|
||||||
|
|
||||||
|
if (survivor.triage_status === TRIAGE.Immediate) {
|
||||||
|
ctx.fillStyle = 'rgba(255, 0, 0, 0.26)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(survivor.x, survivor.y, radius + 6, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = survivor.triage_color || toRgba(TRIAGE.Minimal);
|
||||||
|
ctx.font = 'bold 18px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText('✦', survivor.x, survivor.y);
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(survivor.x, survivor.y, radius, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
if (survivor.depth < 0) {
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = '9px monospace';
|
||||||
|
ctx.fillText(`${Math.abs(survivor.depth).toFixed(1)}m`, survivor.x + radius + 4, survivor.y + 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(ctx, width, height) {
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
ctx.fillStyle = '#0a0e1a';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#1f2a3d';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
const grid = 40;
|
||||||
|
for (let x = 0; x <= width; x += grid) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = 0; y <= height; y += grid) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(width, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderZones(ctx);
|
||||||
|
this.renderSurvivors(ctx);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.font = '12px monospace';
|
||||||
|
const stats = {
|
||||||
|
survivors: this.survivors.size,
|
||||||
|
alerts: this.alerts.size,
|
||||||
|
};
|
||||||
|
ctx.fillText(`Survivors: ${stats.survivors}`, 12, 20);
|
||||||
|
ctx.fillText(`Alerts: ${stats.alerts}`, 12, 36);
|
||||||
|
}
|
||||||
|
|
||||||
|
postMessage(message) {
|
||||||
|
if (typeof window.ReactNativeWebView !== 'undefined' && window.ReactNativeWebView.postMessage) {
|
||||||
|
window.ReactNativeWebView.postMessage(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboard = new MatDashboard();
|
||||||
|
const canvas = document.getElementById('mapCanvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = Math.max(200, Math.floor(canvas.parentElement.clientWidth - 2));
|
||||||
|
canvas.height = Math.max(180, Math.floor(canvas.parentElement.clientHeight - 20));
|
||||||
|
};
|
||||||
|
|
||||||
|
const startup = () => {
|
||||||
|
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' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const loop = () => {
|
||||||
|
if (dashboard.zones.size > 0) {
|
||||||
|
dashboard.render(ctx, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
window.addEventListener('message', (evt) => {
|
||||||
|
let incoming = evt.data;
|
||||||
|
try {
|
||||||
|
if (typeof incoming === 'string') {
|
||||||
|
incoming = JSON.parse(incoming);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
incoming = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!incoming || typeof incoming !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incoming.type === 'CREATE_EVENT') {
|
||||||
|
const payload = incoming.payload || {};
|
||||||
|
dashboard.createEvent(
|
||||||
|
payload.type || payload.disaster_type || 'earthquake',
|
||||||
|
payload.latitude || 0,
|
||||||
|
payload.longitude || 0,
|
||||||
|
payload.name || payload.description || 'Disaster Event',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incoming.type === 'ADD_ZONE') {
|
||||||
|
dashboard.addZoneFromPayload(incoming.payload || {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incoming.type === 'FRAME_UPDATE') {
|
||||||
|
dashboard.processFrame(incoming.payload || {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resize();
|
||||||
|
startup();
|
||||||
|
loop();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
70
mobile/src/components/ConnectionBanner.tsx
Normal file
70
mobile/src/components/ConnectionBanner.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { StyleSheet, View } from 'react-native';
|
||||||
|
import { ThemedText } from './ThemedText';
|
||||||
|
|
||||||
|
type ConnectionState = 'connected' | 'simulated' | 'disconnected';
|
||||||
|
|
||||||
|
type ConnectionBannerProps = {
|
||||||
|
status: ConnectionState;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveState = (status: ConnectionState) => {
|
||||||
|
if (status === 'connected') {
|
||||||
|
return {
|
||||||
|
label: 'LIVE STREAM',
|
||||||
|
backgroundColor: '#0F6B2A',
|
||||||
|
textColor: '#E2FFEA',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'disconnected') {
|
||||||
|
return {
|
||||||
|
label: 'DISCONNECTED',
|
||||||
|
backgroundColor: '#8A1E2A',
|
||||||
|
textColor: '#FFE3E7',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: 'SIMULATED DATA',
|
||||||
|
backgroundColor: '#9A5F0C',
|
||||||
|
textColor: '#FFF3E1',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConnectionBanner = ({ status }: ConnectionBannerProps) => {
|
||||||
|
const state = resolveState(status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.banner,
|
||||||
|
{
|
||||||
|
backgroundColor: state.backgroundColor,
|
||||||
|
borderBottomColor: state.textColor,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ThemedText preset="labelMd" style={[styles.text, { color: state.textColor }]}>
|
||||||
|
{state.label}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
banner: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderBottomWidth: 2,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
letterSpacing: 2,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
});
|
||||||
66
mobile/src/components/ErrorBoundary.tsx
Normal file
66
mobile/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { Button, StyleSheet, View } from 'react-native';
|
||||||
|
import { ThemedText } from './ThemedText';
|
||||||
|
import { ThemedView } from './ThemedView';
|
||||||
|
|
||||||
|
type ErrorBoundaryProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ErrorBoundaryState = {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('ErrorBoundary caught an error', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRetry = () => {
|
||||||
|
this.setState({ hasError: false, error: undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<ThemedView style={styles.container}>
|
||||||
|
<ThemedText preset="displayMd">Something went wrong</ThemedText>
|
||||||
|
<ThemedText preset="bodySm" style={styles.message}>
|
||||||
|
{this.state.error?.message ?? 'An unexpected error occurred.'}
|
||||||
|
</ThemedText>
|
||||||
|
<View style={styles.buttonWrap}>
|
||||||
|
<Button title="Retry" onPress={this.handleRetry} />
|
||||||
|
</View>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
buttonWrap: {
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
96
mobile/src/components/GaugeArc.tsx
Normal file
96
mobile/src/components/GaugeArc.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { StyleSheet, View } from 'react-native';
|
||||||
|
import Animated, { useAnimatedProps, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||||
|
import Svg, { Circle, G, Text as SvgText } from 'react-native-svg';
|
||||||
|
|
||||||
|
type GaugeArcProps = {
|
||||||
|
value: number;
|
||||||
|
max: number;
|
||||||
|
label: string;
|
||||||
|
unit: string;
|
||||||
|
color: string;
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
||||||
|
|
||||||
|
export const GaugeArc = ({ value, max, label, unit, color, size = 140 }: GaugeArcProps) => {
|
||||||
|
const radius = (size - 20) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const arcLength = circumference * 0.75;
|
||||||
|
const strokeWidth = 12;
|
||||||
|
const progress = useSharedValue(0);
|
||||||
|
|
||||||
|
const normalized = Math.max(0, Math.min(max > 0 ? value / max : 0, 1));
|
||||||
|
const displayText = `${value.toFixed(1)} ${unit}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
progress.value = withTiming(normalized, { duration: 600 });
|
||||||
|
}, [normalized, progress]);
|
||||||
|
|
||||||
|
const animatedStroke = useAnimatedProps(() => {
|
||||||
|
const dashOffset = arcLength - arcLength * progress.value;
|
||||||
|
return {
|
||||||
|
strokeDashoffset: dashOffset,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||||
|
<G transform={`rotate(-135 ${size / 2} ${size / 2})`}>
|
||||||
|
<Circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
stroke="#1E293B"
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={`${arcLength} ${circumference}`}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<AnimatedCircle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
stroke={color}
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={`${arcLength} ${circumference}`}
|
||||||
|
strokeLinecap="round"
|
||||||
|
animatedProps={animatedStroke}
|
||||||
|
/>
|
||||||
|
</G>
|
||||||
|
<SvgText
|
||||||
|
x={size / 2}
|
||||||
|
y={size / 2 - 4}
|
||||||
|
fill="#E2E8F0"
|
||||||
|
fontSize={18}
|
||||||
|
fontFamily="Courier New"
|
||||||
|
fontWeight="700"
|
||||||
|
textAnchor="middle"
|
||||||
|
>
|
||||||
|
{displayText}
|
||||||
|
</SvgText>
|
||||||
|
<SvgText
|
||||||
|
x={size / 2}
|
||||||
|
y={size / 2 + 16}
|
||||||
|
fill="#94A3B8"
|
||||||
|
fontSize={10}
|
||||||
|
fontFamily="Courier New"
|
||||||
|
textAnchor="middle"
|
||||||
|
letterSpacing="0.6"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</SvgText>
|
||||||
|
</Svg>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
0
mobile/src/components/HudOverlay.tsx
Normal file
0
mobile/src/components/HudOverlay.tsx
Normal file
60
mobile/src/components/LoadingSpinner.tsx
Normal file
60
mobile/src/components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { StyleSheet, ViewStyle } from 'react-native';
|
||||||
|
import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
|
||||||
|
import Svg, { Circle } from 'react-native-svg';
|
||||||
|
import { colors } from '../theme/colors';
|
||||||
|
|
||||||
|
type LoadingSpinnerProps = {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
style?: ViewStyle;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LoadingSpinner = ({ size = 36, color = colors.accent, style }: LoadingSpinnerProps) => {
|
||||||
|
const rotation = useSharedValue(0);
|
||||||
|
const strokeWidth = Math.max(4, size * 0.14);
|
||||||
|
const center = size / 2;
|
||||||
|
const radius = center - strokeWidth;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rotation.value = withRepeat(withTiming(360, { duration: 900, easing: Easing.linear }), -1);
|
||||||
|
}, [rotation]);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotateZ: `${rotation.value}deg` }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.container, { width: size, height: size }, style, animatedStyle]} pointerEvents="none">
|
||||||
|
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||||
|
<Circle
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
stroke="rgba(255,255,255,0.2)"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<Circle
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={`${circumference * 0.3} ${circumference * 0.7}`}
|
||||||
|
strokeDashoffset={circumference * 0.2}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
71
mobile/src/components/ModeBadge.tsx
Normal file
71
mobile/src/components/ModeBadge.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
import { ThemedText } from './ThemedText';
|
||||||
|
import { colors } from '../theme/colors';
|
||||||
|
|
||||||
|
type Mode = 'CSI' | 'RSSI' | 'SIM' | 'LIVE';
|
||||||
|
|
||||||
|
const modeStyle: Record<
|
||||||
|
Mode,
|
||||||
|
{
|
||||||
|
background: string;
|
||||||
|
border: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
CSI: {
|
||||||
|
background: 'rgba(50, 184, 198, 0.25)',
|
||||||
|
border: colors.accent,
|
||||||
|
color: colors.accent,
|
||||||
|
},
|
||||||
|
RSSI: {
|
||||||
|
background: 'rgba(255, 165, 2, 0.2)',
|
||||||
|
border: colors.warn,
|
||||||
|
color: colors.warn,
|
||||||
|
},
|
||||||
|
SIM: {
|
||||||
|
background: 'rgba(255, 71, 87, 0.18)',
|
||||||
|
border: colors.simulated,
|
||||||
|
color: colors.simulated,
|
||||||
|
},
|
||||||
|
LIVE: {
|
||||||
|
background: 'rgba(46, 213, 115, 0.18)',
|
||||||
|
border: colors.connected,
|
||||||
|
color: colors.connected,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type ModeBadgeProps = {
|
||||||
|
mode: Mode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ModeBadge = ({ mode }: ModeBadgeProps) => {
|
||||||
|
const style = modeStyle[mode];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedText
|
||||||
|
preset="labelMd"
|
||||||
|
style={[
|
||||||
|
styles.badge,
|
||||||
|
{
|
||||||
|
backgroundColor: style.background,
|
||||||
|
borderColor: style.border,
|
||||||
|
color: style.color,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{mode}
|
||||||
|
</ThemedText>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
badge: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 999,
|
||||||
|
borderWidth: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
letterSpacing: 1,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
147
mobile/src/components/OccupancyGrid.tsx
Normal file
147
mobile/src/components/OccupancyGrid.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { StyleProp, ViewStyle } from 'react-native';
|
||||||
|
import Animated, { interpolateColor, useAnimatedProps, useSharedValue, withTiming, type SharedValue } from 'react-native-reanimated';
|
||||||
|
import Svg, { Circle, G, Rect } from 'react-native-svg';
|
||||||
|
import { colors } from '../theme/colors';
|
||||||
|
|
||||||
|
type Point = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OccupancyGridProps = {
|
||||||
|
values: number[];
|
||||||
|
personPositions?: Point[];
|
||||||
|
size?: number;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GRID_DIMENSION = 20;
|
||||||
|
const CELLS = GRID_DIMENSION * GRID_DIMENSION;
|
||||||
|
|
||||||
|
const toColor = (value: number): string => {
|
||||||
|
const clamped = Math.max(0, Math.min(1, value));
|
||||||
|
let r: number;
|
||||||
|
let g: number;
|
||||||
|
let b: number;
|
||||||
|
|
||||||
|
if (clamped < 0.5) {
|
||||||
|
const t = clamped * 2;
|
||||||
|
r = Math.round(255 * 0);
|
||||||
|
g = Math.round(255 * t);
|
||||||
|
b = Math.round(255 * (1 - t));
|
||||||
|
} else {
|
||||||
|
const t = (clamped - 0.5) * 2;
|
||||||
|
r = Math.round(255 * t);
|
||||||
|
g = Math.round(255 * (1 - t));
|
||||||
|
b = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AnimatedRect = Animated.createAnimatedComponent(Rect);
|
||||||
|
|
||||||
|
const normalizeValues = (values: number[]) => {
|
||||||
|
const normalized = new Array(CELLS).fill(0);
|
||||||
|
for (let i = 0; i < CELLS; i += 1) {
|
||||||
|
const value = values?.[i] ?? 0;
|
||||||
|
normalized[i] = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CellProps = {
|
||||||
|
index: number;
|
||||||
|
size: number;
|
||||||
|
progress: SharedValue<number>;
|
||||||
|
previousColors: string[];
|
||||||
|
nextColors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const Cell = ({ index, size, progress, previousColors, nextColors }: CellProps) => {
|
||||||
|
const col = index % GRID_DIMENSION;
|
||||||
|
const row = Math.floor(index / GRID_DIMENSION);
|
||||||
|
const cellSize = size / GRID_DIMENSION;
|
||||||
|
const x = col * cellSize;
|
||||||
|
const y = row * cellSize;
|
||||||
|
|
||||||
|
const animatedProps = useAnimatedProps(() => ({
|
||||||
|
fill: interpolateColor(
|
||||||
|
progress.value,
|
||||||
|
[0, 1],
|
||||||
|
[previousColors[index] ?? colors.surfaceAlt, nextColors[index] ?? colors.surfaceAlt],
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedRect
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={cellSize}
|
||||||
|
height={cellSize}
|
||||||
|
rx={1}
|
||||||
|
animatedProps={animatedProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OccupancyGrid = ({
|
||||||
|
values,
|
||||||
|
personPositions = [],
|
||||||
|
size = 320,
|
||||||
|
style,
|
||||||
|
}: OccupancyGridProps) => {
|
||||||
|
const normalizedValues = useMemo(() => normalizeValues(values), [values]);
|
||||||
|
const previousColors = useRef<string[]>(normalizedValues.map(toColor));
|
||||||
|
const nextColors = useRef<string[]>(normalizedValues.map(toColor));
|
||||||
|
const progress = useSharedValue(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const next = normalizeValues(values);
|
||||||
|
previousColors.current = normalizedValues.map(toColor);
|
||||||
|
nextColors.current = next.map(toColor);
|
||||||
|
progress.value = 0;
|
||||||
|
progress.value = withTiming(1, { duration: 500 });
|
||||||
|
}, [values, normalizedValues, progress]);
|
||||||
|
|
||||||
|
const markers = useMemo(() => {
|
||||||
|
const cellSize = size / GRID_DIMENSION;
|
||||||
|
return personPositions.map(({ x, y }, idx) => {
|
||||||
|
const clampedX = Math.max(0, Math.min(GRID_DIMENSION - 1, Math.round(x)));
|
||||||
|
const clampedY = Math.max(0, Math.min(GRID_DIMENSION - 1, Math.round(y)));
|
||||||
|
const cx = (clampedX + 0.5) * cellSize;
|
||||||
|
const cy = (clampedY + 0.5) * cellSize;
|
||||||
|
const markerRadius = Math.max(3, cellSize * 0.25);
|
||||||
|
return (
|
||||||
|
<Circle
|
||||||
|
key={`person-${idx}`}
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={markerRadius}
|
||||||
|
fill={colors.accent}
|
||||||
|
stroke={colors.textPrimary}
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [personPositions, size]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} style={style} viewBox={`0 0 ${size} ${size}`}>
|
||||||
|
<G>
|
||||||
|
{Array.from({ length: CELLS }).map((_, index) => (
|
||||||
|
<Cell
|
||||||
|
key={index}
|
||||||
|
index={index}
|
||||||
|
size={size}
|
||||||
|
progress={progress}
|
||||||
|
previousColors={previousColors.current}
|
||||||
|
nextColors={nextColors.current}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</G>
|
||||||
|
{markers}
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
62
mobile/src/components/SignalBar.tsx
Normal file
62
mobile/src/components/SignalBar.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { StyleSheet, View } from 'react-native';
|
||||||
|
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||||
|
import { ThemedText } from './ThemedText';
|
||||||
|
import { colors } from '../theme/colors';
|
||||||
|
|
||||||
|
type SignalBarProps = {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clamp01 = (value: number) => Math.max(0, Math.min(1, value));
|
||||||
|
|
||||||
|
export const SignalBar = ({ value, label, color = colors.accent }: SignalBarProps) => {
|
||||||
|
const progress = useSharedValue(clamp01(value));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
progress.value = withTiming(clamp01(value), { duration: 250 });
|
||||||
|
}, [value, progress]);
|
||||||
|
|
||||||
|
const animatedFill = useAnimatedStyle(() => ({
|
||||||
|
width: `${progress.value * 100}%`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ThemedText preset="bodySm" style={styles.label}>
|
||||||
|
{label}
|
||||||
|
</ThemedText>
|
||||||
|
<View style={styles.track}>
|
||||||
|
<Animated.View style={[styles.fill, { backgroundColor: color }, animatedFill]} />
|
||||||
|
</View>
|
||||||
|
<ThemedText preset="bodySm" style={styles.percent}>
|
||||||
|
{Math.round(clamp01(value) * 100)}%
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: colors.surfaceAlt,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
percent: {
|
||||||
|
textAlign: 'right',
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
});
|
||||||
64
mobile/src/components/SparklineChart.tsx
Normal file
64
mobile/src/components/SparklineChart.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { View, ViewStyle } from 'react-native';
|
||||||
|
import { colors } from '../theme/colors';
|
||||||
|
|
||||||
|
type SparklineChartProps = {
|
||||||
|
data: number[];
|
||||||
|
color?: string;
|
||||||
|
height?: number;
|
||||||
|
style?: ViewStyle;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultHeight = 72;
|
||||||
|
|
||||||
|
export const SparklineChart = ({
|
||||||
|
data,
|
||||||
|
color = colors.accent,
|
||||||
|
height = defaultHeight,
|
||||||
|
style,
|
||||||
|
}: SparklineChartProps) => {
|
||||||
|
const normalizedData = data.length > 0 ? data : [0];
|
||||||
|
|
||||||
|
const chartData = useMemo(
|
||||||
|
() =>
|
||||||
|
normalizedData.map((value, index) => ({
|
||||||
|
x: index,
|
||||||
|
y: value,
|
||||||
|
})),
|
||||||
|
[normalizedData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const yValues = normalizedData.map((value) => Number(value) || 0);
|
||||||
|
const yMin = Math.min(...yValues);
|
||||||
|
const yMax = Math.max(...yValues);
|
||||||
|
const yPadding = yMax - yMin === 0 ? 1 : (yMax - yMin) * 0.2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={style}>
|
||||||
|
<View
|
||||||
|
accessibilityRole="image"
|
||||||
|
style={{
|
||||||
|
height,
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 4,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: color,
|
||||||
|
opacity: 0.2,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chartData.map((point) => (
|
||||||
|
<View key={point.x} style={{ position: 'absolute', left: `${(point.x / Math.max(normalizedData.length - 1, 1)) * 100}%` }} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
83
mobile/src/components/StatusDot.tsx
Normal file
83
mobile/src/components/StatusDot.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { StyleSheet, ViewStyle } from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
cancelAnimation,
|
||||||
|
Easing,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
|
withSequence,
|
||||||
|
withTiming,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { colors } from '../theme/colors';
|
||||||
|
|
||||||
|
type StatusType = 'connected' | 'simulated' | 'disconnected' | 'connecting';
|
||||||
|
|
||||||
|
type StatusDotProps = {
|
||||||
|
status: StatusType;
|
||||||
|
size?: number;
|
||||||
|
style?: ViewStyle;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveColor = (status: StatusType): string => {
|
||||||
|
if (status === 'connecting') return colors.warn;
|
||||||
|
return colors[status];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StatusDot = ({ status, size = 10, style }: StatusDotProps) => {
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
const opacity = useSharedValue(1);
|
||||||
|
const isConnecting = status === 'connecting';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isConnecting) {
|
||||||
|
scale.value = withRepeat(
|
||||||
|
withSequence(
|
||||||
|
withTiming(1.35, { duration: 800, easing: Easing.out(Easing.cubic) }),
|
||||||
|
withTiming(1, { duration: 800, easing: Easing.in(Easing.cubic) }),
|
||||||
|
),
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
opacity.value = withRepeat(
|
||||||
|
withSequence(
|
||||||
|
withTiming(0.4, { duration: 800, easing: Easing.out(Easing.quad) }),
|
||||||
|
withTiming(1, { duration: 800, easing: Easing.in(Easing.quad) }),
|
||||||
|
),
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelAnimation(scale);
|
||||||
|
cancelAnimation(opacity);
|
||||||
|
scale.value = 1;
|
||||||
|
opacity.value = 1;
|
||||||
|
}, [isConnecting, opacity, scale]);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scale: scale.value }],
|
||||||
|
opacity: opacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.dot,
|
||||||
|
{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
backgroundColor: resolveColor(status),
|
||||||
|
borderRadius: size / 2,
|
||||||
|
},
|
||||||
|
animatedStyle,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
dot: {
|
||||||
|
borderRadius: 999,
|
||||||
|
},
|
||||||
|
});
|
||||||
28
mobile/src/components/ThemedText.tsx
Normal file
28
mobile/src/components/ThemedText.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { ComponentPropsWithoutRef } from 'react';
|
||||||
|
import { StyleProp, Text, TextStyle } from 'react-native';
|
||||||
|
import { useTheme } from '../hooks/useTheme';
|
||||||
|
import { colors } from '../theme/colors';
|
||||||
|
import { typography } from '../theme/typography';
|
||||||
|
|
||||||
|
type TextPreset = keyof typeof typography;
|
||||||
|
type ColorKey = keyof typeof colors;
|
||||||
|
|
||||||
|
type ThemedTextProps = Omit<ComponentPropsWithoutRef<typeof Text>, 'style'> & {
|
||||||
|
preset?: TextPreset;
|
||||||
|
color?: ColorKey;
|
||||||
|
style?: StyleProp<TextStyle>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ThemedText = ({
|
||||||
|
preset = 'bodyMd',
|
||||||
|
color = 'textPrimary',
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: ThemedTextProps) => {
|
||||||
|
const { colors, typography } = useTheme();
|
||||||
|
|
||||||
|
const presetStyle = (typography as Record<TextPreset, TextStyle>)[preset];
|
||||||
|
const colorStyle = { color: colors[color] };
|
||||||
|
|
||||||
|
return <Text {...props} style={[presetStyle, colorStyle, style]} />;
|
||||||
|
};
|
||||||
24
mobile/src/components/ThemedView.tsx
Normal file
24
mobile/src/components/ThemedView.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { PropsWithChildren, forwardRef } from 'react';
|
||||||
|
import { View, ViewProps } from 'react-native';
|
||||||
|
import { useTheme } from '../hooks/useTheme';
|
||||||
|
|
||||||
|
type ThemedViewProps = PropsWithChildren<ViewProps>;
|
||||||
|
|
||||||
|
export const ThemedView = forwardRef<View, ThemedViewProps>(({ children, style, ...props }, ref) => {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
backgroundColor: colors.bg,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
14
mobile/src/constants/api.ts
Normal file
14
mobile/src/constants/api.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export const API_ROOT = '/api/v1';
|
||||||
|
|
||||||
|
export const API_POSE_STATUS_PATH = '/api/v1/pose/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';
|
||||||
|
export const API_STREAM_STATUS_PATH = '/api/v1/stream/status';
|
||||||
|
export const API_STREAM_POSE_PATH = '/api/v1/stream/pose';
|
||||||
|
export const API_MAT_EVENTS_PATH = '/api/v1/mat/events';
|
||||||
|
|
||||||
|
export const API_HEALTH_PATH = '/health';
|
||||||
|
export const API_HEALTH_SYSTEM_PATH = '/health/health';
|
||||||
|
export const API_HEALTH_READY_PATH = '/health/ready';
|
||||||
|
export const API_HEALTH_LIVE_PATH = '/health/live';
|
||||||
20
mobile/src/constants/simulation.ts
Normal file
20
mobile/src/constants/simulation.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export const SIMULATION_TICK_INTERVAL_MS = 500;
|
||||||
|
export const SIMULATION_GRID_SIZE = 20;
|
||||||
|
|
||||||
|
export const RSSI_BASE_DBM = -45;
|
||||||
|
export const RSSI_AMPLITUDE_DBM = 3;
|
||||||
|
|
||||||
|
export const VARIANCE_BASE = 1.5;
|
||||||
|
export const VARIANCE_AMPLITUDE = 1.0;
|
||||||
|
|
||||||
|
export const MOTION_BAND_MIN = 0.05;
|
||||||
|
export const MOTION_BAND_AMPLITUDE = 0.15;
|
||||||
|
export const BREATHING_BAND_MIN = 0.03;
|
||||||
|
export const BREATHING_BAND_AMPLITUDE = 0.08;
|
||||||
|
|
||||||
|
export const SIGNAL_FIELD_PRESENCE_LEVEL = 0.8;
|
||||||
|
|
||||||
|
export const BREATHING_BPM_MIN = 12;
|
||||||
|
export const BREATHING_BPM_MAX = 24;
|
||||||
|
export const HEART_BPM_MIN = 58;
|
||||||
|
export const HEART_BPM_MAX = 96;
|
||||||
3
mobile/src/constants/websocket.ts
Normal file
3
mobile/src/constants/websocket.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const WS_PATH = '/api/v1/stream/pose';
|
||||||
|
export const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000];
|
||||||
|
export const MAX_RECONNECT_ATTEMPTS = 10;
|
||||||
31
mobile/src/hooks/usePoseStream.ts
Normal file
31
mobile/src/hooks/usePoseStream.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { wsService } from '@/services/ws.service';
|
||||||
|
import { usePoseStore } from '@/stores/poseStore';
|
||||||
|
import { useSettingsStore } from '@/stores/settingsStore';
|
||||||
|
|
||||||
|
export interface UsePoseStreamResult {
|
||||||
|
connectionStatus: ReturnType<typeof usePoseStore.getState>['connectionStatus'];
|
||||||
|
lastFrame: ReturnType<typeof usePoseStore.getState>['lastFrame'];
|
||||||
|
isSimulated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePoseStream(): UsePoseStreamResult {
|
||||||
|
const serverUrl = useSettingsStore((state) => state.serverUrl);
|
||||||
|
const connectionStatus = usePoseStore((state) => state.connectionStatus);
|
||||||
|
const lastFrame = usePoseStore((state) => state.lastFrame);
|
||||||
|
const isSimulated = usePoseStore((state) => state.isSimulated);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = wsService.subscribe((frame) => {
|
||||||
|
usePoseStore.getState().handleFrame(frame);
|
||||||
|
});
|
||||||
|
wsService.connect(serverUrl);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
wsService.disconnect();
|
||||||
|
};
|
||||||
|
}, [serverUrl]);
|
||||||
|
|
||||||
|
return { connectionStatus, lastFrame, isSimulated };
|
||||||
|
}
|
||||||
31
mobile/src/hooks/useRssiScanner.ts
Normal file
31
mobile/src/hooks/useRssiScanner.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { rssiService, type WifiNetwork } from '@/services/rssi.service';
|
||||||
|
import { useSettingsStore } from '@/stores/settingsStore';
|
||||||
|
|
||||||
|
export function useRssiScanner(): { networks: WifiNetwork[]; isScanning: boolean } {
|
||||||
|
const enabled = useSettingsStore((state) => state.rssiScanEnabled);
|
||||||
|
const [networks, setNetworks] = useState<WifiNetwork[]>([]);
|
||||||
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
rssiService.stopScanning();
|
||||||
|
setIsScanning(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = rssiService.subscribe((result) => {
|
||||||
|
setNetworks(result);
|
||||||
|
});
|
||||||
|
rssiService.startScanning(2000);
|
||||||
|
setIsScanning(true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
rssiService.stopScanning();
|
||||||
|
setIsScanning(false);
|
||||||
|
};
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
return { networks, isScanning };
|
||||||
|
}
|
||||||
52
mobile/src/hooks/useServerReachability.ts
Normal file
52
mobile/src/hooks/useServerReachability.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { apiService } from '@/services/api.service';
|
||||||
|
|
||||||
|
interface ServerReachability {
|
||||||
|
reachable: boolean;
|
||||||
|
latencyMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLL_MS = 10000;
|
||||||
|
|
||||||
|
export function useServerReachability(): ServerReachability {
|
||||||
|
const [state, setState] = useState<ServerReachability>({
|
||||||
|
reachable: false,
|
||||||
|
latencyMs: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
const check = async () => {
|
||||||
|
const started = Date.now();
|
||||||
|
try {
|
||||||
|
await apiService.getStatus();
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState({
|
||||||
|
reachable: true,
|
||||||
|
latencyMs: Date.now() - started,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState({
|
||||||
|
reachable: false,
|
||||||
|
latencyMs: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void check();
|
||||||
|
const timer = setInterval(check, POLL_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
4
mobile/src/hooks/useTheme.ts
Normal file
4
mobile/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { ThemeContext, ThemeContextValue } from '../theme/ThemeContext';
|
||||||
|
|
||||||
|
export const useTheme = (): ThemeContextValue => useContext(ThemeContext);
|
||||||
0
mobile/src/hooks/useWebViewBridge.ts
Normal file
0
mobile/src/hooks/useWebViewBridge.ts
Normal file
162
mobile/src/navigation/MainTabs.tsx
Normal file
162
mobile/src/navigation/MainTabs.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import React, { Suspense, useEffect, useState } from 'react';
|
||||||
|
import { ActivityIndicator } from 'react-native';
|
||||||
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { ThemedText } from '../components/ThemedText';
|
||||||
|
import { ThemedView } from '../components/ThemedView';
|
||||||
|
import { colors } from '../theme/colors';
|
||||||
|
import { MainTabsParamList } from './types';
|
||||||
|
|
||||||
|
const createPlaceholder = (label: string) => {
|
||||||
|
const Placeholder = () => (
|
||||||
|
<ThemedView style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<ThemedText preset="bodyLg">{label} screen not implemented yet</ThemedText>
|
||||||
|
<ThemedText preset="bodySm" color="textSecondary">
|
||||||
|
Placeholder shell
|
||||||
|
</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
const LazyPlaceholder = React.lazy(async () => ({ default: Placeholder }));
|
||||||
|
|
||||||
|
const Wrapped = () => (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<ThemedView style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<ActivityIndicator color={colors.accent} />
|
||||||
|
<ThemedText preset="bodySm" color="textSecondary" style={{ marginTop: 8 }}>
|
||||||
|
Loading {label}
|
||||||
|
</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LazyPlaceholder />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
|
||||||
|
return Wrapped;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadScreen = (path: string, label: string) => {
|
||||||
|
const fallback = createPlaceholder(label);
|
||||||
|
return React.lazy(async () => {
|
||||||
|
try {
|
||||||
|
const module = (await import(path)) as { default: React.ComponentType };
|
||||||
|
if (module?.default) {
|
||||||
|
return module;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep fallback for shell-only screens
|
||||||
|
}
|
||||||
|
return { default: fallback } as { default: React.ComponentType };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const LiveScreen = loadScreen('../screens/LiveScreen', 'Live');
|
||||||
|
const VitalsScreen = loadScreen('../screens/VitalsScreen', 'Vitals');
|
||||||
|
const ZonesScreen = loadScreen('../screens/ZonesScreen', 'Zones');
|
||||||
|
const MATScreen = loadScreen('../screens/MATScreen', 'MAT');
|
||||||
|
const SettingsScreen = loadScreen('../screens/SettingsScreen', 'Settings');
|
||||||
|
|
||||||
|
const toIconName = (routeName: keyof MainTabsParamList) => {
|
||||||
|
switch (routeName) {
|
||||||
|
case 'Live':
|
||||||
|
return 'wifi';
|
||||||
|
case 'Vitals':
|
||||||
|
return 'heart';
|
||||||
|
case 'Zones':
|
||||||
|
return 'grid';
|
||||||
|
case 'MAT':
|
||||||
|
return 'shield-checkmark';
|
||||||
|
case 'Settings':
|
||||||
|
return 'settings';
|
||||||
|
default:
|
||||||
|
return 'ellipse';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMatAlertCount = async (): Promise<number> => {
|
||||||
|
try {
|
||||||
|
const mod = (await import('../stores/matStore')) as Record<string, unknown>;
|
||||||
|
const candidates = [mod.useMatStore, mod.useStore].filter((candidate) => {
|
||||||
|
return (
|
||||||
|
!!candidate &&
|
||||||
|
typeof candidate === 'function' &&
|
||||||
|
typeof (candidate as { getState?: () => unknown }).getState === 'function'
|
||||||
|
);
|
||||||
|
}) as Array<{ getState: () => { alerts?: unknown[] } }>;
|
||||||
|
|
||||||
|
for (const store of candidates) {
|
||||||
|
const alerts = store.getState().alerts;
|
||||||
|
if (Array.isArray(alerts)) {
|
||||||
|
return alerts.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const screens: ReadonlyArray<{ name: keyof MainTabsParamList; component: React.ComponentType }> = [
|
||||||
|
{ name: 'Live', component: LiveScreen },
|
||||||
|
{ name: 'Vitals', component: VitalsScreen },
|
||||||
|
{ name: 'Zones', component: ZonesScreen },
|
||||||
|
{ name: 'MAT', component: MATScreen },
|
||||||
|
{ name: 'Settings', component: SettingsScreen },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Tab = createBottomTabNavigator<MainTabsParamList>();
|
||||||
|
|
||||||
|
const Suspended = ({ component: Component }: { component: React.ComponentType }) => (
|
||||||
|
<Suspense fallback={<ActivityIndicator color={colors.accent} />}>
|
||||||
|
<Component />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MainTabs = () => {
|
||||||
|
const [matAlertCount, setMatAlertCount] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const readCount = async () => {
|
||||||
|
const count = await getMatAlertCount();
|
||||||
|
setMatAlertCount(count);
|
||||||
|
};
|
||||||
|
|
||||||
|
void readCount();
|
||||||
|
const timer = setInterval(readCount, 2000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tab.Navigator
|
||||||
|
screenOptions={({ route }) => ({
|
||||||
|
headerShown: false,
|
||||||
|
tabBarActiveTintColor: colors.accent,
|
||||||
|
tabBarInactiveTintColor: colors.textSecondary,
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: '#0D1117',
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
},
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name={toIconName(route.name)} size={size} color={color} />,
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontFamily: 'Courier New',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
tabBarLabel: ({ children, color }) => <ThemedText style={{ color }}>{children}</ThemedText>,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{screens.map(({ name, component }) => (
|
||||||
|
<Tab.Screen
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
options={{
|
||||||
|
tabBarBadge: name === 'MAT' ? (matAlertCount > 0 ? matAlertCount : undefined) : undefined,
|
||||||
|
}}
|
||||||
|
component={() => <Suspended component={component} />}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tab.Navigator>
|
||||||
|
);
|
||||||
|
};
|
||||||
5
mobile/src/navigation/RootNavigator.tsx
Normal file
5
mobile/src/navigation/RootNavigator.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { MainTabs } from './MainTabs';
|
||||||
|
|
||||||
|
export const RootNavigator = () => {
|
||||||
|
return <MainTabs />;
|
||||||
|
};
|
||||||
11
mobile/src/navigation/types.ts
Normal file
11
mobile/src/navigation/types.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export type RootStackParamList = {
|
||||||
|
MainTabs: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MainTabsParamList = {
|
||||||
|
Live: undefined;
|
||||||
|
Vitals: undefined;
|
||||||
|
Zones: undefined;
|
||||||
|
MAT: undefined;
|
||||||
|
Settings: undefined;
|
||||||
|
};
|
||||||
0
mobile/src/screens/LiveScreen/LiveHUD.tsx
Normal file
0
mobile/src/screens/LiveScreen/LiveHUD.tsx
Normal file
0
mobile/src/screens/LiveScreen/index.tsx
Normal file
0
mobile/src/screens/LiveScreen/index.tsx
Normal file
0
mobile/src/screens/LiveScreen/useGaussianBridge.ts
Normal file
0
mobile/src/screens/LiveScreen/useGaussianBridge.ts
Normal file
84
mobile/src/screens/MATScreen/AlertCard.tsx
Normal file
84
mobile/src/screens/MATScreen/AlertCard.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
41
mobile/src/screens/MATScreen/AlertList.tsx
Normal file
41
mobile/src/screens/MATScreen/AlertList.tsx
Normal file
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
26
mobile/src/screens/MATScreen/MatWebView.tsx
Normal file
26
mobile/src/screens/MATScreen/MatWebView.tsx
Normal file
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
89
mobile/src/screens/MATScreen/SurvivorCounter.tsx
Normal file
89
mobile/src/screens/MATScreen/SurvivorCounter.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
138
mobile/src/screens/MATScreen/index.tsx
Normal file
138
mobile/src/screens/MATScreen/index.tsx
Normal file
@@ -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;
|
||||||
118
mobile/src/screens/MATScreen/useMatBridge.ts
Normal file
118
mobile/src/screens/MATScreen/useMatBridge.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
36
mobile/src/screens/SettingsScreen/RssiToggle.tsx
Normal file
36
mobile/src/screens/SettingsScreen/RssiToggle.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
102
mobile/src/screens/SettingsScreen/ServerUrlInput.tsx
Normal file
102
mobile/src/screens/SettingsScreen/ServerUrlInput.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
47
mobile/src/screens/SettingsScreen/ThemePicker.tsx
Normal file
47
mobile/src/screens/SettingsScreen/ThemePicker.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
169
mobile/src/screens/SettingsScreen/index.tsx
Normal file
169
mobile/src/screens/SettingsScreen/index.tsx
Normal file
@@ -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
mobile/src/screens/VitalsScreen/BreathingGauge.tsx
Normal file
0
mobile/src/screens/VitalsScreen/BreathingGauge.tsx
Normal file
0
mobile/src/screens/VitalsScreen/HeartRateGauge.tsx
Normal file
0
mobile/src/screens/VitalsScreen/HeartRateGauge.tsx
Normal file
0
mobile/src/screens/VitalsScreen/MetricCard.tsx
Normal file
0
mobile/src/screens/VitalsScreen/MetricCard.tsx
Normal file
0
mobile/src/screens/VitalsScreen/index.tsx
Normal file
0
mobile/src/screens/VitalsScreen/index.tsx
Normal file
201
mobile/src/screens/ZonesScreen/FloorPlanSvg.tsx
Normal file
201
mobile/src/screens/ZonesScreen/FloorPlanSvg.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
54
mobile/src/screens/ZonesScreen/ZoneLegend.tsx
Normal file
54
mobile/src/screens/ZonesScreen/ZoneLegend.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
82
mobile/src/screens/ZonesScreen/index.tsx
Normal file
82
mobile/src/screens/ZonesScreen/index.tsx
Normal file
@@ -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;
|
||||||
109
mobile/src/screens/ZonesScreen/useOccupancyGrid.ts
Normal file
109
mobile/src/screens/ZonesScreen/useOccupancyGrid.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user