764 lines
25 KiB
TypeScript
764 lines
25 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import {
|
|
View, Text, StyleSheet, TouchableOpacity, ScrollView,
|
|
Modal, ActivityIndicator, useWindowDimensions, Platform,
|
|
useColorScheme,
|
|
} from 'react-native';
|
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
import { useConnectionStore } from '../../src/stores/connectionStore';
|
|
import { useDeviceStore } from '../../src/stores/deviceStore';
|
|
import { useDataStore } from '../../src/stores/dataStore';
|
|
import { useDevice } from '../../src/hooks/useDevice';
|
|
import { useWaveform, type WaveformData } from '../../src/hooks/useWaveform';
|
|
import { WaveformChart } from '../../src/components/WaveformChart';
|
|
import { ParamForm } from '../../src/components/ParamForm';
|
|
import { NoProjectGate } from '../../src/components/NoProjectGate';
|
|
import { CHANNEL_COLORS } from '../../src/protocol/constants';
|
|
import { formatUV, formatUtc, formatCoord, sourceModeLabel, formatBattery, formatTemperature } from '../../src/utils/format';
|
|
import { useTheme } from '../../src/design/tokens';
|
|
import { Spacing, Radius } from '../../src/design/tokens';
|
|
import * as ScreenOrientation from 'expo-screen-orientation';
|
|
import type { MeasurementFrame } from '../../src/protocol/types';
|
|
|
|
// ── Param sheet ───────────────────────────────────────────────────────────────
|
|
|
|
function ParamSheet({
|
|
visible,
|
|
onClose,
|
|
onSetup,
|
|
busy,
|
|
configDirty,
|
|
}: {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
onSetup: () => Promise<void>;
|
|
busy: boolean;
|
|
configDirty: boolean;
|
|
}) {
|
|
const theme = useTheme();
|
|
const { deviceStatus } = useDeviceStore();
|
|
const measuring = deviceStatus !== 'idle';
|
|
|
|
return (
|
|
<Modal
|
|
visible={visible}
|
|
transparent
|
|
animationType="slide"
|
|
onRequestClose={onClose}
|
|
statusBarTranslucent
|
|
>
|
|
<TouchableOpacity style={PS.backdrop} activeOpacity={1} onPress={onClose} />
|
|
<View style={[PS.sheet, {
|
|
backgroundColor: theme.bg.surface,
|
|
borderColor: theme.bg.border,
|
|
}]}>
|
|
<View style={[PS.handle, { backgroundColor: theme.bg.border }]} />
|
|
|
|
<View style={PS.header}>
|
|
<Text style={[PS.title, { color: theme.text.secondary }]}>采集参数</Text>
|
|
{configDirty && (
|
|
<View style={[PS.dirtyBadge, {
|
|
backgroundColor: theme.amber.bg,
|
|
borderColor: theme.amber.border,
|
|
}]}>
|
|
<Text style={[PS.dirtyTxt, { color: theme.amber.fg }]}>待下发</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
<ScrollView style={PS.scroll} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
|
|
<ParamForm />
|
|
<View style={{ height: Spacing.md }} />
|
|
</ScrollView>
|
|
|
|
<TouchableOpacity
|
|
style={[
|
|
PS.setupBtn,
|
|
{
|
|
backgroundColor: theme.bg.raised,
|
|
borderColor: theme.bg.border,
|
|
},
|
|
configDirty && {
|
|
backgroundColor: theme.blue.bg,
|
|
borderColor: theme.blue.fg,
|
|
},
|
|
(busy || measuring) && PS.setupBtnDis,
|
|
]}
|
|
onPress={onSetup}
|
|
disabled={busy || measuring}
|
|
activeOpacity={0.8}
|
|
>
|
|
{busy
|
|
? <ActivityIndicator color={theme.blue.fg} />
|
|
: <Text style={[
|
|
PS.setupBtnTxt,
|
|
{ color: theme.text.muted },
|
|
configDirty && { color: theme.blue.fg },
|
|
]}>
|
|
{measuring ? '采集中,不可下发' : '下 发 配 置'}
|
|
</Text>}
|
|
</TouchableOpacity>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
const PS = StyleSheet.create({
|
|
backdrop: { ...StyleSheet.absoluteFill, backgroundColor: 'rgba(0,0,0,0.6)' },
|
|
sheet: {
|
|
position: 'absolute',
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
maxHeight: '85%',
|
|
borderTopLeftRadius: Radius.xl,
|
|
borderTopRightRadius: Radius.xl,
|
|
borderTopWidth: 1,
|
|
paddingBottom: Spacing.xxl,
|
|
},
|
|
handle: {
|
|
width: 36, height: 4, borderRadius: 2,
|
|
alignSelf: 'center',
|
|
marginTop: Spacing.sm, marginBottom: Spacing.sm,
|
|
},
|
|
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: Spacing.lg, paddingBottom: Spacing.sm, gap: Spacing.sm },
|
|
title: { fontSize: 14, fontWeight: '700', flex: 1 },
|
|
dirtyBadge: { borderRadius: Radius.sm, paddingHorizontal: 7, paddingVertical: 2, borderWidth: 1 },
|
|
dirtyTxt: { fontSize: 9, fontWeight: '700' },
|
|
scroll: { flex: 1 },
|
|
setupBtn: {
|
|
margin: Spacing.lg,
|
|
borderRadius: Radius.lg,
|
|
paddingVertical: 14,
|
|
alignItems: 'center',
|
|
borderWidth: 1,
|
|
},
|
|
setupBtnDis: { opacity: 0.4 },
|
|
setupBtnTxt: { fontWeight: '700', fontSize: 14, letterSpacing: 3 },
|
|
});
|
|
|
|
// ── Control row ───────────────────────────────────────────────────────────────
|
|
|
|
function ControlRow({
|
|
deviceStatus,
|
|
busy,
|
|
frameId,
|
|
onContinuous,
|
|
onSingle,
|
|
onStop,
|
|
onSettings,
|
|
configDirty,
|
|
}: {
|
|
deviceStatus: 'idle' | 'running' | 'single';
|
|
busy: boolean;
|
|
frameId: number | undefined;
|
|
onContinuous: () => void;
|
|
onSingle: () => void;
|
|
onStop: () => void;
|
|
onSettings: () => void;
|
|
configDirty: boolean;
|
|
}) {
|
|
const theme = useTheme();
|
|
const idle = deviceStatus === 'idle';
|
|
const running = deviceStatus === 'running';
|
|
const single = deviceStatus === 'single';
|
|
|
|
return (
|
|
<View style={[CR.row, {
|
|
borderTopColor: theme.bg.border,
|
|
backgroundColor: theme.bg.surface,
|
|
}]}>
|
|
{/* Primary action */}
|
|
{idle && (
|
|
<TouchableOpacity
|
|
style={[CR.primary, {
|
|
backgroundColor: theme.green.bg,
|
|
borderColor: theme.green.border,
|
|
}, !busy ? null : CR.dis]}
|
|
onPress={onContinuous}
|
|
disabled={busy}
|
|
activeOpacity={0.85}
|
|
>
|
|
{busy
|
|
? <ActivityIndicator color={theme.green.fg} />
|
|
: <>
|
|
<Text style={[CR.primaryIcon, { color: theme.text.ghost }]}>▶</Text>
|
|
<Text style={[CR.primaryTxt, { color: theme.green.fg }]}>连 续 采 集</Text>
|
|
</>}
|
|
</TouchableOpacity>
|
|
)}
|
|
{running && (
|
|
<TouchableOpacity
|
|
style={[CR.primary, {
|
|
backgroundColor: theme.red.bg,
|
|
borderColor: theme.red.border,
|
|
}]}
|
|
onPress={onStop}
|
|
activeOpacity={0.85}
|
|
>
|
|
<Text style={[CR.primaryIcon, { color: theme.text.ghost }]}>■</Text>
|
|
<Text style={[CR.primaryTxt, { color: theme.red.fg }]}>停 止</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
{single && (
|
|
<View style={[CR.primary, {
|
|
backgroundColor: theme.teal.bg,
|
|
borderColor: theme.teal.border,
|
|
}, CR.dis]}>
|
|
<ActivityIndicator color={theme.teal.fg} size="small" />
|
|
<Text style={[CR.primaryTxt, { color: theme.teal.fg }]}>单次采集中…</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* Secondary: single-shot button (idle only) */}
|
|
{idle && (
|
|
<TouchableOpacity
|
|
style={[CR.secondary, {
|
|
backgroundColor: theme.teal.bg,
|
|
borderColor: theme.teal.border,
|
|
}, !busy ? null : CR.dis]}
|
|
onPress={onSingle}
|
|
disabled={busy}
|
|
activeOpacity={0.85}
|
|
>
|
|
<Text style={[CR.secondaryIcon, { color: theme.text.ghost }]}>◎</Text>
|
|
<Text style={[CR.secondaryTxt, { color: theme.teal.fg }]}>单次</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
|
|
{/* Running indicator (running only) */}
|
|
{running && frameId !== undefined && (
|
|
<View style={[CR.indicator, {
|
|
backgroundColor: theme.green.bg,
|
|
borderColor: theme.green.border,
|
|
}]}>
|
|
<View style={[CR.runDot, { backgroundColor: theme.green.fg }]} />
|
|
<Text style={[CR.indicatorTxt, { color: theme.green.fg }]}>#{frameId}</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* Settings button — always present */}
|
|
<TouchableOpacity
|
|
style={[CR.settingsBtn, {
|
|
backgroundColor: theme.bg.raised,
|
|
borderColor: theme.bg.border,
|
|
}]}
|
|
onPress={onSettings}
|
|
activeOpacity={0.8}
|
|
>
|
|
<Text style={[CR.settingsTxt, { color: theme.text.muted }, configDirty && { color: theme.amber.fg }]}>⚙</Text>
|
|
{configDirty && (
|
|
<View style={[CR.dirtyDot, {
|
|
backgroundColor: theme.amber.fg,
|
|
borderColor: theme.bg.surface,
|
|
}]} />
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const CR = StyleSheet.create({
|
|
row: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: Spacing.md,
|
|
paddingVertical: Spacing.sm,
|
|
gap: Spacing.sm,
|
|
borderTopWidth: 1,
|
|
},
|
|
primary: {
|
|
flex: 2,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 8,
|
|
paddingVertical: 14,
|
|
borderRadius: Radius.lg,
|
|
borderWidth: 1,
|
|
},
|
|
dis: { opacity: 0.35 },
|
|
primaryIcon: { fontSize: 11 },
|
|
primaryTxt: { fontSize: 13, fontWeight: '700', letterSpacing: 1.5 },
|
|
|
|
secondary: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 5,
|
|
paddingVertical: 14,
|
|
borderRadius: Radius.lg,
|
|
borderWidth: 1,
|
|
},
|
|
secondaryIcon:{ fontSize: 11 },
|
|
secondaryTxt: { fontSize: 12, fontWeight: '700' },
|
|
|
|
indicator: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 6,
|
|
paddingVertical: 14,
|
|
borderRadius: Radius.lg,
|
|
borderWidth: 1,
|
|
},
|
|
runDot: { width: 6, height: 6, borderRadius: 3 },
|
|
indicatorTxt: { fontSize: 12, fontWeight: '700', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
|
|
|
settingsBtn: {
|
|
width: 44, height: 44,
|
|
borderRadius: Radius.md,
|
|
borderWidth: 1,
|
|
alignItems: 'center', justifyContent: 'center',
|
|
},
|
|
settingsTxt: { fontSize: 18 },
|
|
dirtyDot: {
|
|
position: 'absolute', top: 6, right: 6,
|
|
width: 7, height: 7, borderRadius: 4,
|
|
borderWidth: 1,
|
|
},
|
|
});
|
|
|
|
// ── Status panel ─────────────────────────────────────────────────────────────
|
|
|
|
const CURRENT_RATIO = 50;
|
|
const INT32_MAX = 0x7fffffff;
|
|
|
|
function formatCurrent(raw: number): string {
|
|
const amps = (raw / INT32_MAX) * 5.0 * CURRENT_RATIO;
|
|
return `${amps.toFixed(1)} A`;
|
|
}
|
|
|
|
function StatusPanel({ frame }: { frame: MeasurementFrame }) {
|
|
const theme = useTheme();
|
|
const m = frame.meta;
|
|
const mono = { fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' } as const;
|
|
|
|
const row1 = [
|
|
{ label: '源模式', value: sourceModeLabel(m.sourceMode) },
|
|
{ label: '发射电流', value: formatCurrent(m.current) },
|
|
{ label: '电池', value: formatBattery(m.batteryVolt) },
|
|
{ label: '温度', value: formatTemperature(m.temperature) },
|
|
];
|
|
const row2 = [
|
|
{ label: 'Roll', value: `${m.roll.toFixed(1)}°` },
|
|
{ label: 'Pitch', value: `${m.pitch.toFixed(1)}°` },
|
|
{ label: 'Yaw', value: `${m.yaw.toFixed(1)}°` },
|
|
{ label: 'GPS', value: ['未定位','非差分','差分','无效PPS','固定解','浮点解','估算中'][m.gpsStatus] ?? '未知', highlight: m.gpsStatus > 0 },
|
|
{ label: 'SD', value: ['正常','未插卡','异常'][m.sdStatus] ?? '未知', highlight: m.sdStatus === 0 },
|
|
];
|
|
|
|
const renderCell = (it: { label: string; value: string; highlight?: boolean }) => (
|
|
<View key={it.label} style={[SP.cell, { borderColor: theme.bg.border, backgroundColor: theme.bg.surface }]}>
|
|
<Text style={[SP.label, { color: theme.text.muted }]}>{it.label}</Text>
|
|
<Text style={[SP.value, mono, { color: it.highlight ? theme.green.fg : theme.text.secondary }]}>{it.value}</Text>
|
|
</View>
|
|
);
|
|
|
|
return (
|
|
<View style={[SP.container, { borderTopColor: theme.bg.divider, backgroundColor: theme.bg.void }]}>
|
|
<View style={SP.row}>{row1.map(renderCell)}</View>
|
|
<View style={SP.row}>{row2.map(renderCell)}</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const SP = StyleSheet.create({
|
|
container: { paddingHorizontal: Spacing.md, paddingVertical: 4, gap: 4, borderTopWidth: StyleSheet.hairlineWidth },
|
|
row: { flexDirection: 'row', gap: 4 },
|
|
cell: { flex: 1, alignItems: 'center', paddingVertical: 3, borderRadius: Radius.sm, borderWidth: 1 },
|
|
label: { fontSize: 8, fontWeight: '600' },
|
|
value: { fontSize: 10, fontWeight: '700', marginTop: 1 },
|
|
});
|
|
|
|
// ── Frame summary bar ─────────────────────────────────────────────────────────
|
|
|
|
function FrameSummaryBar({ frame }: { frame: MeasurementFrame }) {
|
|
const theme = useTheme();
|
|
return (
|
|
<View style={[FS.bar, {
|
|
backgroundColor: theme.bg.void,
|
|
borderTopColor: theme.bg.divider,
|
|
}]}>
|
|
<Text style={[FS.txt, { color: theme.text.ghost }]}>
|
|
#{frame.frameId}
|
|
{' '}{frame.meta ? formatUtc(frame.meta.utc) : ''}
|
|
{frame.meta?.latitude ? ` ${formatCoord(frame.meta.latitude, false)} ${formatCoord(frame.meta.longitude, true)}` : ''}
|
|
{frame.meta ? ` R${frame.meta.roll.toFixed(1)} P${frame.meta.pitch.toFixed(1)} Y${frame.meta.yaw.toFixed(1)}` : ''}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const FS = StyleSheet.create({
|
|
bar: { paddingHorizontal: Spacing.md, paddingVertical: 5, borderTopWidth: StyleSheet.hairlineWidth },
|
|
txt: { fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
|
});
|
|
|
|
// ── Not connected placeholder ─────────────────────────────────────────────────
|
|
|
|
function NotConnected({ onConnect }: { onConnect: () => void }) {
|
|
const theme = useTheme();
|
|
return (
|
|
<View style={NC.root}>
|
|
<Text style={[NC.icon, { color: theme.bg.border }]}>◈</Text>
|
|
<Text style={[NC.title, { color: theme.text.muted }]}>未连接仪器</Text>
|
|
<Text style={[NC.sub, { color: theme.text.ghost }]}>请先连接 TEM 接收机</Text>
|
|
<TouchableOpacity
|
|
style={[NC.btn, {
|
|
backgroundColor: theme.blue.bg,
|
|
borderColor: theme.blue.fg,
|
|
}]}
|
|
onPress={onConnect}
|
|
activeOpacity={0.8}
|
|
>
|
|
<Text style={[NC.btnTxt, { color: theme.blue.fg }]}>连 接 设 备</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const NC = StyleSheet.create({
|
|
root: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12 },
|
|
icon: { fontSize: 48 },
|
|
title: { fontSize: 17, fontWeight: '700' },
|
|
sub: { fontSize: 12 },
|
|
btn: {
|
|
marginTop: Spacing.sm,
|
|
borderRadius: Radius.lg,
|
|
paddingVertical: 13,
|
|
paddingHorizontal: 32,
|
|
borderWidth: 1,
|
|
},
|
|
btnTxt: { fontSize: 14, fontWeight: '700', letterSpacing: 2 },
|
|
});
|
|
|
|
// ── Main screen ───────────────────────────────────────────────────────────────
|
|
|
|
export default function WaveScreen() {
|
|
const theme = useTheme();
|
|
const { width: sw, height: sh } = useWindowDimensions();
|
|
const { status, showModal } = useConnectionStore();
|
|
const { config, configDirty } = useDeviceStore();
|
|
const { deviceStatus } = useDeviceStore();
|
|
const { busy, setup, startContinuous, startSingle, stop } = useDevice();
|
|
|
|
const frame = useDataStore((s) => s.currentFrame);
|
|
const sessionId = useDataStore((s) => s.sessionId);
|
|
const projectId = useDataStore((s) => s.projectId);
|
|
const hasProject = useDataStore((s) => s.hasProject);
|
|
|
|
const [visible, setVisible] = useState(() => Array(6).fill(true));
|
|
const [logScale, setLogScale] = useState(false);
|
|
const [sheetVisible, setSheetVisible] = useState(false);
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
const [projectName, setProjectName] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (fullscreen) {
|
|
ScreenOrientation.unlockAsync();
|
|
} else {
|
|
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
|
|
}
|
|
return () => {
|
|
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
|
|
};
|
|
}, [fullscreen]);
|
|
|
|
const connected = status === 'connected';
|
|
const channelNum = config.channelNum;
|
|
const CHART_H = Math.round(Math.min(280, sh * 0.36));
|
|
|
|
const data = useWaveform(visible);
|
|
|
|
useEffect(() => {
|
|
if (!projectId) { setProjectName(null); return; }
|
|
import('../../src/services/StorageService').then(({ listProjects }) =>
|
|
listProjects().then((ps) => setProjectName(ps.find((p) => p.projectId === projectId)?.name ?? null)),
|
|
).catch(() => setProjectName(null));
|
|
}, [projectId]);
|
|
|
|
const toggleChannel = (idx: number) =>
|
|
setVisible((prev) => prev.map((v, i) => (i === idx ? !v : v)));
|
|
|
|
const handleSetup = async () => {
|
|
await setup();
|
|
setSheetVisible(false);
|
|
};
|
|
|
|
if (!connected) {
|
|
return (
|
|
<View style={[S.root, { backgroundColor: theme.bg.base }]}>
|
|
<NotConnected onConnect={showModal} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (!hasProject) {
|
|
return (
|
|
<View style={[S.root, { backgroundColor: theme.bg.base }]}>
|
|
<NoProjectGate />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={[S.root, { backgroundColor: theme.bg.base }]}>
|
|
|
|
{/* ── Context bar: project · session ── */}
|
|
<View style={[S.ctxBar, {
|
|
backgroundColor: theme.bg.void,
|
|
borderBottomColor: theme.bg.divider,
|
|
}]}>
|
|
{projectName
|
|
? <Text style={[S.ctxTxt, { color: theme.text.ghost }]} numberOfLines={1}>{projectName} · {sessionId}</Text>
|
|
: <Text style={[S.ctxTxt, { color: theme.text.ghost }]} numberOfLines={1}>{sessionId}</Text>}
|
|
</View>
|
|
|
|
{/* ── Channel selector + LOG/LIN ── */}
|
|
<View style={[S.toolbar, { borderBottomColor: theme.bg.divider }]}>
|
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={S.chRow}>
|
|
{Array.from({ length: channelNum }, (_, i) => (
|
|
<TouchableOpacity
|
|
key={i}
|
|
style={[
|
|
S.chBtn,
|
|
{ borderColor: theme.bg.border },
|
|
visible[i] && { borderColor: CHANNEL_COLORS[i] + '99', backgroundColor: CHANNEL_COLORS[i] + '1a' },
|
|
]}
|
|
onPress={() => toggleChannel(i)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<View style={[S.chDot, { backgroundColor: visible[i] ? CHANNEL_COLORS[i] : theme.bg.border }]} />
|
|
<Text style={[S.chTxt, { color: theme.text.ghost }, visible[i] && { color: CHANNEL_COLORS[i] }]}>CH{i + 1}</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</ScrollView>
|
|
<TouchableOpacity
|
|
style={[S.logBtn, {
|
|
borderColor: theme.bg.border,
|
|
backgroundColor: theme.bg.raised,
|
|
}, !logScale && {
|
|
borderColor: theme.blue.border,
|
|
backgroundColor: theme.blue.bg,
|
|
}]}
|
|
onPress={() => setLogScale((v) => !v)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text style={[S.logTxt, { color: theme.text.muted }, !logScale && { color: theme.blue.fg }]}>
|
|
{logScale ? 'LOG' : 'LIN'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[S.fullBtn, {
|
|
borderColor: theme.bg.border,
|
|
backgroundColor: theme.bg.raised,
|
|
}]}
|
|
onPress={() => setFullscreen(true)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text style={[S.fullTxt, { color: theme.text.muted }]}>⛶</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* ── Waveform chart ── */}
|
|
<View style={[S.chartWrap, { borderBottomColor: theme.bg.divider }]}>
|
|
{data ? (
|
|
<WaveformChart
|
|
data={data}
|
|
visibleChannels={visible}
|
|
width={sw}
|
|
height={CHART_H}
|
|
logScale={logScale}
|
|
interactive={false}
|
|
/>
|
|
) : (
|
|
<View style={[S.chartPlaceholder, { height: CHART_H, backgroundColor: theme.bg.surface }]}>
|
|
<Text style={[S.placeholderTxt, { color: theme.text.ghost }]}>等待采样数据…</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* ── Peak values ── */}
|
|
|
|
{/* ── Frame summary ── */}
|
|
{frame && <StatusPanel frame={frame} />}
|
|
{frame && <FrameSummaryBar frame={frame} />}
|
|
|
|
{/* ── Control row ── */}
|
|
<ControlRow
|
|
deviceStatus={deviceStatus}
|
|
busy={busy}
|
|
frameId={frame?.frameId}
|
|
onContinuous={startContinuous}
|
|
onSingle={startSingle}
|
|
onStop={stop}
|
|
onSettings={() => setSheetVisible(true)}
|
|
configDirty={configDirty}
|
|
/>
|
|
|
|
{/* ── Param sheet ── */}
|
|
<ParamSheet
|
|
visible={sheetVisible}
|
|
onClose={() => setSheetVisible(false)}
|
|
onSetup={handleSetup}
|
|
busy={busy}
|
|
configDirty={configDirty}
|
|
/>
|
|
|
|
{/* ── Fullscreen waveform ── */}
|
|
<Modal
|
|
visible={fullscreen}
|
|
animationType="fade"
|
|
supportedOrientations={['portrait', 'landscape', 'landscape-left', 'landscape-right']}
|
|
statusBarTranslucent
|
|
onRequestClose={() => setFullscreen(false)}
|
|
>
|
|
<FullscreenContent
|
|
data={data}
|
|
visibleChannels={visible}
|
|
logScale={logScale}
|
|
onToggleLog={() => setLogScale((v) => !v)}
|
|
onClose={() => setFullscreen(false)}
|
|
/>
|
|
</Modal>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function FullscreenContent({
|
|
data, visibleChannels, logScale, onToggleLog, onClose,
|
|
}: {
|
|
data: WaveformData | null;
|
|
visibleChannels: boolean[];
|
|
logScale: boolean;
|
|
onToggleLog: () => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const theme = useTheme();
|
|
const scheme = useColorScheme();
|
|
const isDark = scheme !== 'light';
|
|
const { width: fsW, height: fsH } = useWindowDimensions();
|
|
return (
|
|
<GestureHandlerRootView style={[S.fsRoot, { backgroundColor: theme.chart.bg }]}>
|
|
{data ? (
|
|
<WaveformChart
|
|
data={data}
|
|
visibleChannels={visibleChannels}
|
|
width={fsW}
|
|
height={fsH}
|
|
logScale={logScale}
|
|
interactive
|
|
/>
|
|
) : (
|
|
<View style={[S.chartPlaceholder, { flex: 1, backgroundColor: theme.bg.surface }]}>
|
|
<Text style={[S.placeholderTxt, { color: theme.text.ghost }]}>等待采样数据…</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* Floating toolbar */}
|
|
<View style={S.fsToolbar}>
|
|
<TouchableOpacity
|
|
style={[S.logBtn, {
|
|
borderColor: theme.bg.border,
|
|
backgroundColor: theme.bg.raised,
|
|
}, !logScale && {
|
|
borderColor: theme.blue.border,
|
|
backgroundColor: theme.blue.bg,
|
|
}]}
|
|
onPress={onToggleLog}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text style={[S.logTxt, { color: theme.text.muted }, !logScale && { color: theme.blue.fg }]}>
|
|
{logScale ? 'LOG' : 'LIN'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[S.fsCloseBtn, {
|
|
backgroundColor: isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)',
|
|
}]}
|
|
onPress={onClose}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text style={[S.fsCloseTxt, { color: isDark ? '#ffffff' : '#000000' }]}>✕</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</GestureHandlerRootView>
|
|
);
|
|
}
|
|
|
|
const S = StyleSheet.create({
|
|
root: { flex: 1 },
|
|
|
|
ctxBar: {
|
|
paddingHorizontal: Spacing.md,
|
|
paddingVertical: 4,
|
|
borderBottomWidth: 1,
|
|
},
|
|
ctxTxt: {
|
|
fontSize: 9,
|
|
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
|
|
},
|
|
|
|
toolbar: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingRight: Spacing.sm,
|
|
borderBottomWidth: 1,
|
|
},
|
|
chRow: { flexDirection: 'row', paddingHorizontal: Spacing.sm, paddingVertical: 6, gap: 5 },
|
|
chBtn: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 5,
|
|
borderRadius: Radius.md,
|
|
borderWidth: 1,
|
|
},
|
|
chDot: { width: 6, height: 6, borderRadius: 3 },
|
|
chTxt: { fontSize: 10, fontWeight: '600' },
|
|
logBtn: {
|
|
paddingHorizontal: 9,
|
|
paddingVertical: 5,
|
|
borderRadius: Radius.sm,
|
|
borderWidth: 1,
|
|
marginLeft: 4,
|
|
},
|
|
logTxt: { fontSize: 10, fontWeight: '700', letterSpacing: 0.5 },
|
|
fullBtn: {
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 5,
|
|
borderRadius: Radius.sm,
|
|
borderWidth: 1,
|
|
marginLeft: 4,
|
|
},
|
|
fullTxt: { fontSize: 12 },
|
|
|
|
fsRoot: { flex: 1 },
|
|
fsToolbar: {
|
|
position: 'absolute',
|
|
top: 40,
|
|
right: 12,
|
|
flexDirection: 'row',
|
|
gap: 8,
|
|
},
|
|
fsCloseBtn: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 18,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
fsCloseTxt: { fontSize: 16, fontWeight: '700' },
|
|
|
|
chartWrap: { borderBottomWidth: StyleSheet.hairlineWidth },
|
|
chartPlaceholder: {
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
placeholderTxt: { fontSize: 13 },
|
|
});
|