2026-06-19 22:08:10 +08:00

559 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useMemo } from 'react';
import {
View, Text, StyleSheet, TouchableOpacity, ScrollView,
Modal, ActivityIndicator, useWindowDimensions, Platform,
} from 'react-native';
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 } from '../../src/hooks/useWaveform';
import { WaveformChart } from '../../src/components/WaveformChart';
import { ParamForm } from '../../src/components/ParamForm';
import { CHANNEL_COLORS } from '../../src/protocol/constants';
import { formatUV, formatUtc, formatCoord } from '../../src/utils/format';
import { Colors, Spacing, Radius } from '../../src/design/tokens';
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 { 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}>
<View style={PS.handle} />
<View style={PS.header}>
<Text style={PS.title}></Text>
{configDirty && (
<View style={PS.dirtyBadge}>
<Text style={PS.dirtyTxt}></Text>
</View>
)}
</View>
<ScrollView style={PS.scroll} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
<ParamForm />
<View style={{ height: Spacing.md }} />
</ScrollView>
<TouchableOpacity
style={[
PS.setupBtn,
configDirty && PS.setupBtnDirty,
(busy || measuring) && PS.setupBtnDis,
]}
onPress={onSetup}
disabled={busy || measuring}
activeOpacity={0.8}
>
{busy
? <ActivityIndicator color={Colors.blue.fg} />
: <Text style={[PS.setupBtnTxt, configDirty && PS.setupBtnTxtDirty]}>
{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%',
backgroundColor: Colors.bg.surface,
borderTopLeftRadius: Radius.xl,
borderTopRightRadius: Radius.xl,
borderTopWidth: 1,
borderColor: Colors.bg.border,
paddingBottom: Spacing.xxl,
},
handle: {
width: 36, height: 4, borderRadius: 2,
backgroundColor: Colors.bg.border,
alignSelf: 'center',
marginTop: Spacing.sm, marginBottom: Spacing.sm,
},
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: Spacing.lg, paddingBottom: Spacing.sm, gap: Spacing.sm },
title: { color: Colors.text.secondary, fontSize: 14, fontWeight: '700', flex: 1 },
dirtyBadge: { backgroundColor: Colors.amber.bg, borderRadius: Radius.sm, paddingHorizontal: 7, paddingVertical: 2, borderWidth: 1, borderColor: Colors.amber.border },
dirtyTxt: { color: Colors.amber.fg, fontSize: 9, fontWeight: '700' },
scroll: { flex: 1 },
setupBtn: {
margin: Spacing.lg,
backgroundColor: Colors.bg.raised,
borderRadius: Radius.lg,
paddingVertical: 14,
alignItems: 'center',
borderWidth: 1,
borderColor: Colors.bg.border,
},
setupBtnDirty: { backgroundColor: Colors.blue.bg, borderColor: Colors.blue.fg },
setupBtnDis: { opacity: 0.4 },
setupBtnTxt: { color: Colors.text.muted, fontWeight: '700', fontSize: 14, letterSpacing: 3 },
setupBtnTxtDirty: { color: Colors.blue.fg },
});
// ── 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 idle = deviceStatus === 'idle';
const running = deviceStatus === 'running';
const single = deviceStatus === 'single';
return (
<View style={CR.row}>
{/* Primary action */}
{idle && (
<TouchableOpacity
style={[CR.primary, CR.green, !busy ? null : CR.dis]}
onPress={onContinuous}
disabled={busy}
activeOpacity={0.85}
>
{busy
? <ActivityIndicator color={Colors.green.fg} />
: <>
<Text style={CR.primaryIcon}></Text>
<Text style={[CR.primaryTxt, { color: Colors.green.fg }]}> </Text>
</>}
</TouchableOpacity>
)}
{running && (
<TouchableOpacity
style={[CR.primary, CR.red]}
onPress={onStop}
activeOpacity={0.85}
>
<Text style={CR.primaryIcon}></Text>
<Text style={[CR.primaryTxt, { color: Colors.red.fg }]}> </Text>
</TouchableOpacity>
)}
{single && (
<View style={[CR.primary, CR.teal, CR.dis]}>
<ActivityIndicator color={Colors.teal.fg} size="small" />
<Text style={[CR.primaryTxt, { color: Colors.teal.fg }]}></Text>
</View>
)}
{/* Secondary: single-shot button (idle only) */}
{idle && (
<TouchableOpacity
style={[CR.secondary, CR.tealBtn, !busy ? null : CR.dis]}
onPress={onSingle}
disabled={busy}
activeOpacity={0.85}
>
<Text style={CR.secondaryIcon}></Text>
<Text style={[CR.secondaryTxt, { color: Colors.teal.fg }]}></Text>
</TouchableOpacity>
)}
{/* Running indicator (running only) */}
{running && frameId !== undefined && (
<View style={CR.indicator}>
<View style={CR.runDot} />
<Text style={CR.indicatorTxt}>#{frameId}</Text>
</View>
)}
{/* Settings button — always present */}
<TouchableOpacity style={CR.settingsBtn} onPress={onSettings} activeOpacity={0.8}>
<Text style={[CR.settingsTxt, configDirty && CR.settingsDirty]}></Text>
{configDirty && <View style={CR.dirtyDot} />}
</TouchableOpacity>
</View>
);
}
const CR = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
gap: Spacing.sm,
borderTopWidth: 1,
borderTopColor: Colors.bg.border,
backgroundColor: Colors.bg.surface,
},
primary: {
flex: 2,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingVertical: 14,
borderRadius: Radius.lg,
borderWidth: 1,
},
green: { backgroundColor: Colors.green.bg, borderColor: Colors.green.border },
red: { backgroundColor: Colors.red.bg, borderColor: Colors.red.border },
teal: { backgroundColor: Colors.teal.bg, borderColor: Colors.teal.border },
dis: { opacity: 0.35 },
primaryIcon: { fontSize: 11, color: '#ffffff88' },
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,
},
tealBtn: { backgroundColor: Colors.teal.bg, borderColor: Colors.teal.border },
secondaryIcon:{ fontSize: 11, color: '#ffffff66' },
secondaryTxt: { fontSize: 12, fontWeight: '700' },
indicator: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 14,
borderRadius: Radius.lg,
backgroundColor: Colors.green.bg,
borderWidth: 1,
borderColor: Colors.green.border,
},
runDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: Colors.green.fg },
indicatorTxt: { color: Colors.green.fg, fontSize: 12, fontWeight: '700', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
settingsBtn: {
width: 44, height: 44,
borderRadius: Radius.md,
backgroundColor: Colors.bg.raised,
borderWidth: 1, borderColor: Colors.bg.border,
alignItems: 'center', justifyContent: 'center',
},
settingsTxt: { fontSize: 18, color: Colors.text.muted },
settingsDirty: { color: Colors.amber.fg },
dirtyDot: {
position: 'absolute', top: 6, right: 6,
width: 7, height: 7, borderRadius: 4,
backgroundColor: Colors.amber.fg,
borderWidth: 1, borderColor: Colors.bg.surface,
},
});
// ── Peak row ──────────────────────────────────────────────────────────────────
function PeakRow({ frame, visible, channelNum }: { frame: MeasurementFrame; visible: boolean[]; channelNum: number }) {
return (
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={PK.scroll}>
<View style={PK.row}>
{frame.adcUV.map((ch, i) => {
if (i >= channelNum || !visible[i]) return null;
const pk = ch.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
return (
<View key={i} style={PK.cell}>
<View style={[PK.dot, { backgroundColor: CHANNEL_COLORS[i] }]} />
<Text style={PK.label}>CH{i + 1}</Text>
<Text style={[PK.value, { color: CHANNEL_COLORS[i] + 'cc' }]}>{formatUV(pk)}</Text>
</View>
);
})}
</View>
</ScrollView>
);
}
const PK = StyleSheet.create({
scroll: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: Colors.bg.divider },
row: { flexDirection: 'row', paddingHorizontal: Spacing.md, paddingVertical: 7, gap: 10 },
cell: { flexDirection: 'row', alignItems: 'center', gap: 4, backgroundColor: Colors.bg.surface, borderRadius: Radius.sm, paddingHorizontal: 8, paddingVertical: 4, borderWidth: 1, borderColor: Colors.bg.border },
dot: { width: 5, height: 5, borderRadius: 3 },
label: { color: Colors.text.ghost, fontSize: 9, fontWeight: '600' },
value: { fontSize: 11, fontWeight: '600', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
});
// ── Frame summary bar ─────────────────────────────────────────────────────────
function FrameSummaryBar({ frame }: { frame: MeasurementFrame }) {
const m = frame.meta;
return (
<View style={FS.bar}>
<Text style={FS.txt}>
#{frame.frameId}
{' '}×{frame.accNum}
{m ? ` ${formatUtc(m.utc)}` : ''}
{m && m.latitude ? ` ${formatCoord(m.latitude, false)} ${formatCoord(m.longitude, true)}` : ''}
</Text>
</View>
);
}
const FS = StyleSheet.create({
bar: { paddingHorizontal: Spacing.md, paddingVertical: 5, backgroundColor: Colors.bg.void, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: Colors.bg.divider },
txt: { color: Colors.text.ghost, fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
});
// ── Not connected placeholder ─────────────────────────────────────────────────
function NotConnected({ onConnect }: { onConnect: () => void }) {
return (
<View style={NC.root}>
<Text style={NC.icon}></Text>
<Text style={NC.title}></Text>
<Text style={NC.sub}> TEM </Text>
<TouchableOpacity style={NC.btn} onPress={onConnect} activeOpacity={0.8}>
<Text style={NC.btnTxt}> </Text>
</TouchableOpacity>
</View>
);
}
const NC = StyleSheet.create({
root: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12 },
icon: { fontSize: 48, color: Colors.bg.border },
title: { color: Colors.text.muted, fontSize: 17, fontWeight: '700' },
sub: { color: Colors.text.ghost, fontSize: 12 },
btn: {
marginTop: Spacing.sm,
backgroundColor: Colors.blue.bg,
borderRadius: Radius.lg,
paddingVertical: 13,
paddingHorizontal: 32,
borderWidth: 1, borderColor: Colors.blue.fg,
},
btnTxt: { color: Colors.blue.fg, fontSize: 14, fontWeight: '700', letterSpacing: 2 },
});
// ── Main screen ───────────────────────────────────────────────────────────────
export default function WaveScreen() {
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 [visible, setVisible] = useState(() => Array(6).fill(true));
const [logScale, setLogScale] = useState(true);
const [sheetVisible, setSheetVisible] = useState(false);
const [projectName, setProjectName] = useState<string | null>(null);
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)),
);
}, [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}>
<NotConnected onConnect={showModal} />
</View>
);
}
return (
<View style={S.root}>
{/* ── Context bar: project · session ── */}
<View style={S.ctxBar}>
{projectName
? <Text style={S.ctxTxt} numberOfLines={1}>{projectName} · {sessionId}</Text>
: <Text style={S.ctxTxt} numberOfLines={1}>{sessionId}</Text>}
</View>
{/* ── Channel selector + LOG/LIN ── */}
<View style={S.toolbar}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={S.chRow}>
{Array.from({ length: channelNum }, (_, i) => (
<TouchableOpacity
key={i}
style={[
S.chBtn,
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] : Colors.bg.border }]} />
<Text style={[S.chTxt, visible[i] && { color: CHANNEL_COLORS[i] }]}>CH{i + 1}</Text>
</TouchableOpacity>
))}
</ScrollView>
<TouchableOpacity
style={[S.logBtn, !logScale && S.logBtnOn]}
onPress={() => setLogScale((v) => !v)}
activeOpacity={0.7}
>
<Text style={[S.logTxt, !logScale && S.logTxtOn]}>{logScale ? 'LOG' : 'LIN'}</Text>
</TouchableOpacity>
</View>
{/* ── Waveform chart ── */}
<View style={S.chartWrap}>
{data ? (
<WaveformChart
data={data}
visibleChannels={visible}
width={sw}
height={CHART_H}
logScale={logScale}
/>
) : (
<View style={[S.chartPlaceholder, { height: CHART_H }]}>
<Text style={S.placeholderTxt}></Text>
</View>
)}
</View>
{/* ── Peak values ── */}
{frame && <PeakRow frame={frame} visible={visible} channelNum={channelNum} />}
{/* ── Frame summary ── */}
{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}
/>
</View>
);
}
const S = StyleSheet.create({
root: { flex: 1, backgroundColor: Colors.bg.base },
ctxBar: {
paddingHorizontal: Spacing.md,
paddingVertical: 4,
backgroundColor: Colors.bg.void,
borderBottomWidth: 1,
borderBottomColor: Colors.bg.divider,
},
ctxTxt: {
color: Colors.text.ghost,
fontSize: 9,
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
},
toolbar: {
flexDirection: 'row',
alignItems: 'center',
paddingRight: Spacing.sm,
borderBottomWidth: 1,
borderBottomColor: Colors.bg.divider,
},
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,
borderColor: Colors.bg.border,
},
chDot: { width: 6, height: 6, borderRadius: 3 },
chTxt: { color: Colors.text.ghost, fontSize: 10, fontWeight: '600' },
logBtn: {
paddingHorizontal: 9,
paddingVertical: 5,
borderRadius: Radius.sm,
borderWidth: 1,
borderColor: Colors.bg.border,
backgroundColor: Colors.bg.raised,
marginLeft: 4,
},
logBtnOn: { borderColor: Colors.blue.border, backgroundColor: Colors.blue.bg },
logTxt: { color: Colors.text.muted, fontSize: 10, fontWeight: '700', letterSpacing: 0.5 },
logTxtOn: { color: Colors.blue.fg },
chartWrap: { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: Colors.bg.divider },
chartPlaceholder: {
backgroundColor: Colors.bg.surface,
justifyContent: 'center',
alignItems: 'center',
},
placeholderTxt: { color: Colors.text.ghost, fontSize: 13 },
});