init
This commit is contained in:
+41
-48
@@ -1,67 +1,60 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Platform } from 'react-native';
|
||||
import { SymbolView } from 'expo-symbols';
|
||||
import { Link, Tabs } from 'expo-router';
|
||||
import { Platform, Pressable } from 'react-native';
|
||||
|
||||
import Colors from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
import { useClientOnlyValue } from '@/components/useClientOnlyValue';
|
||||
const ACTIVE = '#4a9eff';
|
||||
const INACTIVE = '#555';
|
||||
const BG = '#111111';
|
||||
|
||||
// SymbolView tintColor is typed as string but Tabs passes ColorValue — cast to avoid TS error
|
||||
function TabIcon({ name, color }: { name: string; color: any }) {
|
||||
return <SymbolView name={name as any} tintColor={color} size={22} />;
|
||||
}
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme].tint,
|
||||
// Disable the static render of the header on web
|
||||
// to prevent a hydration error in React Navigation v6.
|
||||
headerShown: useClientOnlyValue(false, true),
|
||||
}}>
|
||||
headerShown: false,
|
||||
tabBarStyle: { backgroundColor: BG, borderTopColor: '#222' },
|
||||
tabBarActiveTintColor: ACTIVE,
|
||||
tabBarInactiveTintColor: INACTIVE,
|
||||
tabBarLabelStyle: { fontSize: 10 },
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
name="control"
|
||||
options={{
|
||||
title: 'Tab One',
|
||||
title: '控制台',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<SymbolView
|
||||
name={{
|
||||
ios: 'chevron.left.forwardslash.chevron.right',
|
||||
android: 'code',
|
||||
web: 'code',
|
||||
}}
|
||||
tintColor={color}
|
||||
size={28}
|
||||
/>
|
||||
),
|
||||
headerRight: () => (
|
||||
<Link href="/modal" asChild>
|
||||
<Pressable style={{ marginRight: 15 }}>
|
||||
{({ pressed }) => (
|
||||
<SymbolView
|
||||
name={{ ios: 'info.circle', android: 'info', web: 'info' }}
|
||||
size={25}
|
||||
tintColor={Colors[colorScheme].text}
|
||||
style={{ opacity: pressed ? 0.5 : 1 }}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</Link>
|
||||
<TabIcon name={Platform.select({ ios: 'slider.horizontal.3', android: 'tune', web: 'tune' })!} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="two"
|
||||
name="waveform"
|
||||
options={{
|
||||
title: 'Tab Two',
|
||||
title: '波形',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<SymbolView
|
||||
name={{
|
||||
ios: 'chevron.left.forwardslash.chevron.right',
|
||||
android: 'code',
|
||||
web: 'code',
|
||||
}}
|
||||
tintColor={color}
|
||||
size={28}
|
||||
/>
|
||||
<TabIcon name={Platform.select({ ios: 'waveform', android: 'show_chart', web: 'show_chart' })!} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="map"
|
||||
options={{
|
||||
title: '地图',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<TabIcon name={Platform.select({ ios: 'map', android: 'map', web: 'map' })!} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="records"
|
||||
options={{
|
||||
title: '记录',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<TabIcon name={Platform.select({ ios: 'list.bullet', android: 'list', web: 'list' })!} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, ScrollView, ActivityIndicator } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { DeviceStatusBar } from '../../src/components/DeviceStatusBar';
|
||||
import { ParamForm } from '../../src/components/ParamForm';
|
||||
import { useDevice } from '../../src/hooks/useDevice';
|
||||
import { useDeviceStore } from '../../src/stores/deviceStore';
|
||||
import { useDataStore } from '../../src/stores/dataStore';
|
||||
|
||||
export default function ControlScreen() {
|
||||
const { busy, connected, deviceStatus, setup, startContinuous, startSingle, stop } = useDevice();
|
||||
const frame = useDataStore((s) => s.currentFrame);
|
||||
const [configDirty, setConfigDirty] = useState(false);
|
||||
|
||||
const handleSetup = async () => {
|
||||
await setup();
|
||||
setConfigDirty(false);
|
||||
};
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<View style={styles.notConnected}>
|
||||
<Text style={styles.notConnectedText}>未连接设备</Text>
|
||||
<TouchableOpacity style={styles.connectBtn} onPress={() => router.replace('/connect')}>
|
||||
<Text style={styles.connectBtnText}>前往连接</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const running = deviceStatus === 'running';
|
||||
const canStart = !running && !busy;
|
||||
const canStop = (running || deviceStatus === 'single') && !busy;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<DeviceStatusBar />
|
||||
|
||||
{/* Control buttons */}
|
||||
<View style={styles.ctrlRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.ctrlBtn, styles.btnStart, !canStart && styles.btnDisabled]}
|
||||
onPress={startContinuous}
|
||||
disabled={!canStart}
|
||||
>
|
||||
{busy ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.ctrlText}>连续采集</Text>}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.ctrlBtn, styles.btnSingle, !canStart && styles.btnDisabled]}
|
||||
onPress={startSingle}
|
||||
disabled={!canStart}
|
||||
>
|
||||
<Text style={styles.ctrlText}>单次</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.ctrlBtn, styles.btnStop, !canStop && styles.btnDisabled]}
|
||||
onPress={stop}
|
||||
disabled={!canStop}
|
||||
>
|
||||
<Text style={styles.ctrlText}>停止</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Last frame info */}
|
||||
{frame && (
|
||||
<View style={styles.frameInfo}>
|
||||
<Text style={styles.frameText}>
|
||||
帧 #{frame.frameId} 叠加 {frame.meta ? frame.accNum : '--'} 次 通道 {frame.meta?.channelNum ?? '-'}
|
||||
</Text>
|
||||
<Text style={styles.frameText}>
|
||||
电流峰值: {frame.meta?.current ?? '--'} 温度: {frame.meta?.temperature ?? '--'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Param form */}
|
||||
<ScrollView style={styles.formScroll} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.formHeader}>
|
||||
<Text style={styles.formTitle}>采集参数</Text>
|
||||
{configDirty && <Text style={styles.dirtyBadge}>未下发</Text>}
|
||||
</View>
|
||||
<ParamForm onAnyChange={() => setConfigDirty(true)} />
|
||||
<TouchableOpacity
|
||||
style={[styles.setupBtn, busy && styles.btnDisabled]}
|
||||
onPress={handleSetup}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? <ActivityIndicator color="#fff" /> : <Text style={styles.setupBtnText}>下 发 配 置</Text>}
|
||||
</TouchableOpacity>
|
||||
<View style={{ height: 24 }} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#0d0d0d' },
|
||||
notConnected: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 16, backgroundColor: '#0d0d0d' },
|
||||
notConnectedText: { color: '#666', fontSize: 16 },
|
||||
connectBtn: { backgroundColor: '#4a9eff', borderRadius: 8, paddingVertical: 10, paddingHorizontal: 24 },
|
||||
connectBtnText: { color: '#fff', fontWeight: '600' },
|
||||
ctrlRow: { flexDirection: 'row', gap: 10, padding: 12 },
|
||||
ctrlBtn: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
btnStart: { backgroundColor: '#1a5c2a' },
|
||||
btnSingle: { backgroundColor: '#1a3a5c' },
|
||||
btnStop: { backgroundColor: '#5c1a1a' },
|
||||
btnDisabled: { opacity: 0.4 },
|
||||
ctrlText: { color: '#fff', fontWeight: '700', fontSize: 14 },
|
||||
frameInfo: {
|
||||
marginHorizontal: 12,
|
||||
backgroundColor: '#181818',
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
frameText: { color: '#888', fontSize: 11, fontFamily: 'monospace' },
|
||||
formScroll: { flex: 1 },
|
||||
formHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
formTitle: { color: '#4a9eff', fontSize: 13, fontWeight: '600' },
|
||||
dirtyBadge: {
|
||||
backgroundColor: '#5c3a0a',
|
||||
color: '#ffaa00',
|
||||
fontSize: 10,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
setupBtn: {
|
||||
margin: 14,
|
||||
backgroundColor: '#4a9eff',
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
setupBtnText: { color: '#fff', fontWeight: '700', fontSize: 15, letterSpacing: 2 },
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Platform } from 'react-native';
|
||||
import MapView, { Marker, Polyline, type Region } from 'react-native-maps';
|
||||
import { useDataStore } from '../../src/stores/dataStore';
|
||||
import { formatCoord } from '../../src/utils/format';
|
||||
|
||||
export default function MapScreen() {
|
||||
const history = useDataStore((s) => s.history);
|
||||
const mapRef = useRef<MapView>(null);
|
||||
|
||||
const points = history
|
||||
.filter((f) => f.meta && f.meta.latitude !== 0 && f.meta.longitude !== 0)
|
||||
.map((f) => ({
|
||||
latitude: f.meta!.latitude,
|
||||
longitude: f.meta!.longitude,
|
||||
frameId: f.frameId,
|
||||
}))
|
||||
.reverse(); // oldest first for polyline
|
||||
|
||||
const latest = points[points.length - 1];
|
||||
|
||||
const centerOnCurrent = () => {
|
||||
if (!latest || !mapRef.current) return;
|
||||
mapRef.current.animateToRegion(
|
||||
{ latitude: latest.latitude, longitude: latest.longitude, latitudeDelta: 0.005, longitudeDelta: 0.005 },
|
||||
600,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MapView
|
||||
ref={mapRef}
|
||||
style={styles.map}
|
||||
mapType="satellite"
|
||||
showsUserLocation
|
||||
initialRegion={
|
||||
latest
|
||||
? { latitude: latest.latitude, longitude: latest.longitude, latitudeDelta: 0.01, longitudeDelta: 0.01 }
|
||||
: { latitude: 30, longitude: 115, latitudeDelta: 10, longitudeDelta: 10 }
|
||||
}
|
||||
>
|
||||
{/* GPS track polyline */}
|
||||
{points.length > 1 && (
|
||||
<Polyline coordinates={points} strokeColor="#4a9eff" strokeWidth={2} />
|
||||
)}
|
||||
|
||||
{/* Measurement point markers */}
|
||||
{points.map((p) => (
|
||||
<Marker
|
||||
key={p.frameId}
|
||||
coordinate={{ latitude: p.latitude, longitude: p.longitude }}
|
||||
anchor={{ x: 0.5, y: 0.5 }}
|
||||
title={`帧 #${p.frameId}`}
|
||||
>
|
||||
<View style={styles.markerDot} />
|
||||
</Marker>
|
||||
))}
|
||||
</MapView>
|
||||
|
||||
{/* Overlay info */}
|
||||
<View style={styles.overlay}>
|
||||
<Text style={styles.overlayText}>测点数: {points.length}</Text>
|
||||
{latest && (
|
||||
<Text style={styles.overlayText}>
|
||||
{formatCoord(latest.latitude, false)} {formatCoord(latest.longitude, true)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Center button */}
|
||||
<TouchableOpacity style={styles.centerBtn} onPress={centerOnCurrent}>
|
||||
<Text style={styles.centerBtnText}>⌖</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
map: { flex: 1 },
|
||||
markerDot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: '#4a9eff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#fff',
|
||||
},
|
||||
overlay: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 12,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
gap: 2,
|
||||
},
|
||||
overlayText: { color: '#ddd', fontSize: 11 },
|
||||
centerBtn: {
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
right: 16,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: 'rgba(10,10,10,0.85)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#444',
|
||||
},
|
||||
centerBtnText: { color: '#4a9eff', fontSize: 22 },
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { useDataStore } from '../../src/stores/dataStore';
|
||||
import { exportCsv, shareFile } from '../../src/utils/export';
|
||||
import { formatUtc, formatCoord, formatUV } from '../../src/utils/format';
|
||||
import type { MeasurementFrame } from '../../src/protocol/types';
|
||||
|
||||
export default function RecordsScreen() {
|
||||
const { history, sessionId, clearHistory } = useDataStore();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const handleExportAll = async () => {
|
||||
if (history.length === 0) {
|
||||
Alert.alert('无数据', '当前会话没有采集记录');
|
||||
return;
|
||||
}
|
||||
setExporting(true);
|
||||
try {
|
||||
const path = await exportCsv(history, sessionId);
|
||||
await shareFile(path);
|
||||
} catch (e: any) {
|
||||
Alert.alert('导出失败', e.message);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
Alert.alert('清空记录', '确定清空当前会话的所有记录?', [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: '清空', style: 'destructive', onPress: clearHistory },
|
||||
]);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: MeasurementFrame }) => {
|
||||
const m = item.meta;
|
||||
const peakUV = item.adcUV[0]
|
||||
? item.adcUV[0].reduce((mx, v) => Math.max(mx, Math.abs(v)), 0)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<View style={styles.item}>
|
||||
<View style={styles.itemHeader}>
|
||||
<Text style={styles.frameId}>#{item.frameId}</Text>
|
||||
<Text style={styles.time}>{m ? formatUtc(m.utc) : '--'}</Text>
|
||||
</View>
|
||||
{m && (
|
||||
<>
|
||||
<Text style={styles.coord}>
|
||||
{formatCoord(m.latitude, false)} {formatCoord(m.longitude, true)}
|
||||
</Text>
|
||||
<View style={styles.itemFooter}>
|
||||
<Text style={styles.meta}>叠加 {item.accNum}次</Text>
|
||||
<Text style={styles.meta}>增益 ×{item.gain}</Text>
|
||||
<Text style={styles.meta}>CH1峰值 {formatUV(peakUV)}</Text>
|
||||
<View style={[styles.gpsIndicator, { backgroundColor: m.gpsStatus > 0 ? '#1a4a1a' : '#2a1a1a' }]}>
|
||||
<Text style={{ color: m.gpsStatus > 0 ? '#44cc44' : '#884444', fontSize: 9 }}>
|
||||
GPS {m.gpsStatus > 0 ? '✓' : '✗'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Toolbar */}
|
||||
<View style={styles.toolbar}>
|
||||
<Text style={styles.sessionText}>会话: {sessionId}</Text>
|
||||
<Text style={styles.countText}>{history.length} 测点</Text>
|
||||
<View style={styles.toolbarBtns}>
|
||||
<TouchableOpacity
|
||||
style={[styles.toolBtn, styles.exportBtn, exporting && styles.btnDisabled]}
|
||||
onPress={handleExportAll}
|
||||
disabled={exporting}
|
||||
>
|
||||
{exporting ? (
|
||||
<ActivityIndicator color="#44cc44" size="small" />
|
||||
) : (
|
||||
<Text style={styles.exportText}>导出 CSV</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.toolBtn, styles.clearBtn]} onPress={handleClear}>
|
||||
<Text style={styles.clearText}>清空</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{history.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyText}>暂无采集记录</Text>
|
||||
<Text style={styles.emptyHint}>在控制台开始采集后,数据将显示在这里</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={history}
|
||||
keyExtractor={(item) => String(item.frameId)}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={styles.list}
|
||||
ItemSeparatorComponent={() => <View style={styles.sep} />}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#0d0d0d' },
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 10,
|
||||
backgroundColor: '#111',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#2a2a2a',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
sessionText: { color: '#555', fontSize: 10, flex: 1 },
|
||||
countText: { color: '#888', fontSize: 12, fontWeight: '600' },
|
||||
toolbarBtns: { flexDirection: 'row', gap: 8 },
|
||||
toolBtn: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6 },
|
||||
exportBtn: { backgroundColor: '#1a3a1a', borderWidth: 1, borderColor: '#2a5a2a' },
|
||||
exportText: { color: '#44cc44', fontSize: 12, fontWeight: '600' },
|
||||
clearBtn: { backgroundColor: '#3a1a1a', borderWidth: 1, borderColor: '#5a2a2a' },
|
||||
clearText: { color: '#ff6666', fontSize: 12 },
|
||||
btnDisabled: { opacity: 0.4 },
|
||||
list: { padding: 10, gap: 8 },
|
||||
item: {
|
||||
backgroundColor: '#181818',
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#2a2a2a',
|
||||
},
|
||||
itemHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
|
||||
frameId: { color: '#4a9eff', fontSize: 12, fontWeight: '700', fontFamily: 'monospace' },
|
||||
time: { color: '#666', fontSize: 10, fontFamily: 'monospace' },
|
||||
coord: { color: '#888', fontSize: 10, marginBottom: 4 },
|
||||
itemFooter: { flexDirection: 'row', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
|
||||
meta: { color: '#555', fontSize: 10 },
|
||||
gpsIndicator: { borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 },
|
||||
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#222' },
|
||||
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 8 },
|
||||
emptyText: { color: '#444', fontSize: 16 },
|
||||
emptyHint: { color: '#333', fontSize: 12 },
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, useWindowDimensions, ScrollView } from 'react-native';
|
||||
import { DeviceStatusBar } from '../../src/components/DeviceStatusBar';
|
||||
import { WaveformChart } from '../../src/components/WaveformChart';
|
||||
import { ChannelSelector } from '../../src/components/ChannelSelector';
|
||||
import { useWaveform } from '../../src/hooks/useWaveform';
|
||||
import { useDataStore } from '../../src/stores/dataStore';
|
||||
import { useDeviceStore } from '../../src/stores/deviceStore';
|
||||
import { formatUV, formatUtc, formatCoord } from '../../src/utils/format';
|
||||
|
||||
export default function WaveformScreen() {
|
||||
const { width } = useWindowDimensions();
|
||||
const channelNum = useDeviceStore((s) => s.config.channelNum);
|
||||
const [visible, setVisible] = useState(() => Array(6).fill(true));
|
||||
|
||||
const frame = useDataStore((s) => s.currentFrame);
|
||||
const data = useWaveform(visible);
|
||||
|
||||
const toggleChannel = (idx: number) => {
|
||||
setVisible((prev) => prev.map((v, i) => (i === idx ? !v : v)));
|
||||
};
|
||||
|
||||
const CHART_HEIGHT = 280;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<DeviceStatusBar />
|
||||
|
||||
<ChannelSelector maxChannels={channelNum} visible={visible} onToggle={toggleChannel} />
|
||||
|
||||
{/* Waveform chart */}
|
||||
<View style={styles.chartWrap}>
|
||||
{data ? (
|
||||
<WaveformChart
|
||||
data={data}
|
||||
visibleChannels={visible}
|
||||
width={width}
|
||||
height={CHART_HEIGHT}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.placeholder, { height: CHART_HEIGHT }]}>
|
||||
<Text style={styles.placeholderText}>等待采样数据...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Channel peak values */}
|
||||
{frame && (
|
||||
<ScrollView style={styles.peakScroll}>
|
||||
<View style={styles.peakRow}>
|
||||
{frame.adcUV.map((ch, idx) => {
|
||||
if (!visible[idx] || idx >= channelNum) return null;
|
||||
const absMax = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
|
||||
const peak = ch[0] < 0 ? -absMax : absMax;
|
||||
return (
|
||||
<View key={idx} style={styles.peakCell}>
|
||||
<Text style={styles.peakLabel}>CH{idx + 1}</Text>
|
||||
<Text style={styles.peakValue}>{formatUV(absMax)}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Frame metadata */}
|
||||
{frame.meta && (
|
||||
<View style={styles.metaBlock}>
|
||||
<Text style={styles.metaText}>
|
||||
帧 #{frame.frameId} UTC: {formatUtc(frame.meta.utc)}
|
||||
</Text>
|
||||
<Text style={styles.metaText}>
|
||||
{formatCoord(frame.meta.latitude, false)} {formatCoord(frame.meta.longitude, true)} 海拔 {frame.meta.altitude.toFixed(1)} m
|
||||
</Text>
|
||||
<Text style={styles.metaText}>
|
||||
Roll {frame.meta.roll.toFixed(1)}° Pitch {frame.meta.pitch.toFixed(1)}° Yaw {frame.meta.yaw.toFixed(1)}°
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#0d0d0d' },
|
||||
chartWrap: { borderBottomWidth: 1, borderBottomColor: '#222' },
|
||||
placeholder: {
|
||||
backgroundColor: '#1a1a1a',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
placeholderText: { color: '#444', fontSize: 14 },
|
||||
peakScroll: { flex: 1 },
|
||||
peakRow: { flexDirection: 'row', flexWrap: 'wrap', padding: 12, gap: 10 },
|
||||
peakCell: {
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
minWidth: 90,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#2a2a2a',
|
||||
},
|
||||
peakLabel: { color: '#888', fontSize: 11 },
|
||||
peakValue: { color: '#eee', fontSize: 13, fontWeight: '600', marginTop: 2, fontFamily: 'monospace' },
|
||||
metaBlock: {
|
||||
margin: 12,
|
||||
backgroundColor: '#111',
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
gap: 3,
|
||||
},
|
||||
metaText: { color: '#555', fontSize: 10, fontFamily: 'monospace' },
|
||||
});
|
||||
Reference in New Issue
Block a user