TriloopTem_App/app/(tabs)/profile.tsx
2026-06-19 22:08:10 +08:00

699 lines
29 KiB
TypeScript

import React, { useState, useEffect, useMemo } from 'react';
import {
View, Text, StyleSheet, TouchableOpacity, Modal, FlatList,
ActivityIndicator, TextInput, useWindowDimensions, Platform, ScrollView,
} from 'react-native';
import { Canvas, Path, Skia, Line, vec } from '@shopify/react-native-skia';
import { useDataStore } from '../../src/stores/dataStore';
import { useDeviceStore } from '../../src/stores/deviceStore';
import * as StorageService from '../../src/services/StorageService';
import * as BinLoader from '../../src/services/BinLoader';
import type { MeasurementFrame } from '../../src/protocol/types';
import type { SessionInfo } from '../../src/services/StorageService';
import { CHANNEL_COLORS } from '../../src/protocol/constants';
// ── Constants ──────────────────────────────────────────────────────────────
const CP = { top: 14, right: 14, bottom: 34, left: 52 };
const CANVAS_H = 70;
const SAMPLE_FREQ_HZ: Record<number, number> = {
0x00: 250000, 0x01: 125000, 0x02: 62500, 0x03: 31250,
0x04: 15600, 0x05: 7800, 0x06: 3900, 0x07: 1950,
0x08: 977, 0x09: 488, 0x0a: 244, 0x0b: 122, 0x0c: 61,
};
const JET_HEX: string[] = Array.from({ length: 256 }, (_, i) => {
const t = i / 255;
const r = Math.round(Math.max(0, Math.min(255, (1.5 - Math.abs(4 * t - 3)) * 255)));
const g = Math.round(Math.max(0, Math.min(255, (1.5 - Math.abs(4 * t - 2)) * 255)));
const b = Math.round(Math.max(0, Math.min(255, (1.5 - Math.abs(4 * t - 1)) * 255)));
const h = (v: number) => v.toString(16).padStart(2, '0');
return `#${h(r)}${h(g)}${h(b)}`;
});
// ── Gate Config ────────────────────────────────────────────────────────────
import type { GateConfig, GateSpacing } from '../../src/protocol/types';
function computeGatePositions(cfg: GateConfig, sampleDepth: number, hz: number): number[] {
if (sampleDepth === 0 || hz === 0 || cfg.count <= 0 || cfg.tStart >= cfg.tEnd) return [];
const totalUs = (sampleDepth / hz) * 1e6;
return Array.from({ length: cfg.count }, (_, i) => {
const t = cfg.count === 1 ? 0.5 : i / (cfg.count - 1);
const tUs = cfg.spacing === 'log'
? cfg.tStart * Math.pow(cfg.tEnd / cfg.tStart, t)
: cfg.tStart + t * (cfg.tEnd - cfg.tStart);
return Math.max(0.001, Math.min(0.999, tUs / totalUs));
});
}
function gateColor(idx: number, total: number): string {
const t = total <= 1 ? 0.5 : idx / (total - 1);
return JET_HEX[Math.round(t * 255)];
}
function fmtUs(us: number): string {
if (us >= 1000) return `${(us / 1000).toFixed(2)}ms`;
return `${us.toFixed(0)}μs`;
}
// ── Types ──────────────────────────────────────────────────────────────────
interface LoadedFrame {
frameId: number;
adcUV: Float64Array[];
sampleDepth: number;
channelNum: number;
}
// ── Utility ────────────────────────────────────────────────────────────────
function niceStep(range: number, count: number): number {
const rough = range / count;
const mag = Math.pow(10, Math.floor(Math.log10(rough)));
for (const f of [1, 2, 2.5, 5, 10]) if (f * mag >= rough) return f * mag;
return 10 * mag;
}
function formatUVShort(uv: number): string {
const a = Math.abs(uv);
if (a >= 1e6) return `${(uv / 1e6).toFixed(1)}V`;
if (a >= 1e3) return `${(uv / 1e3).toFixed(1)}mV`;
if (a >= 1) return `${uv.toFixed(0)}μV`;
return `${(uv * 1e3).toFixed(1)}nV`;
}
// ── useProfileData ─────────────────────────────────────────────────────────
function useProfileData(sessionId: string, currentSessionId: string, history: MeasurementFrame[]) {
const [histFrames, setHistFrames] = useState<LoadedFrame[]>([]);
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0 });
const liveFrames = useMemo<LoadedFrame[]>(() => {
if (sessionId !== currentSessionId) return [];
return [...history].reverse().map(f => ({
frameId: f.frameId,
adcUV: f.adcUV,
sampleDepth: f.adcUV[0]?.length ?? 0,
channelNum: f.adcUV.length,
}));
}, [sessionId, currentSessionId, history]);
useEffect(() => {
if (sessionId === currentSessionId) { setHistFrames([]); return; }
setLoading(true);
setHistFrames([]);
let cancelled = false;
(async () => {
const persisted = await StorageService.loadSessionFrames(sessionId);
setProgress({ current: 0, total: persisted.length });
const result: LoadedFrame[] = [];
for (let i = 0; i < persisted.length; i++) {
if (cancelled) return;
const pf = persisted[i];
if (pf.binPath) {
const loaded = await BinLoader.loadBinFile(pf.binPath);
if (loaded) result.push({ ...loaded, frameId: pf.frameId });
}
setProgress({ current: i + 1, total: persisted.length });
}
if (!cancelled) { setHistFrames(result); setLoading(false); }
})();
return () => { cancelled = true; };
}, [sessionId, currentSessionId]);
return {
frames: sessionId === currentSessionId ? liveFrames : histFrames,
loading: sessionId !== currentSessionId && loading,
progress,
};
}
// ── GateSettingsModal ──────────────────────────────────────────────────────
function GateSettingsModal({ visible, config, onApply, onClose, sampleDepth, hz }: {
visible: boolean;
config: GateConfig;
onApply: (cfg: GateConfig) => void;
onClose: () => void;
sampleDepth: number;
hz: number;
}) {
const [tStartStr, setTStartStr] = useState(String(config.tStart));
const [tEndStr, setTEndStr] = useState(String(config.tEnd));
const [countStr, setCountStr] = useState(String(config.count));
const [spacing, setSpacing] = useState<GateSpacing>(config.spacing);
useEffect(() => {
if (visible) {
setTStartStr(String(config.tStart));
setTEndStr(String(config.tEnd));
setCountStr(String(config.count));
setSpacing(config.spacing);
}
}, [visible, config]);
const maxUs = sampleDepth > 0 && hz > 0 ? (sampleDepth / hz) * 1e6 : 100000;
const handleApply = () => {
const tStart = Math.max(1, parseFloat(tStartStr) || config.tStart);
const tEnd = Math.min(Math.max(tStart + 1, parseFloat(tEndStr) || config.tEnd), maxUs * 0.999);
const count = Math.max(1, Math.min(64, parseInt(countStr, 10) || config.count));
onApply({ tStart, tEnd, count, spacing });
onClose();
};
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<TouchableOpacity style={GSM.backdrop} activeOpacity={1} onPress={onClose} />
<View style={GSM.sheet}>
<Text style={GSM.title}></Text>
<View style={GSM.row}>
<View style={GSM.field}>
<Text style={GSM.label}> (μs)</Text>
<TextInput
style={GSM.input}
keyboardType="numeric"
value={tStartStr}
onChangeText={setTStartStr}
/>
</View>
<View style={GSM.field}>
<Text style={GSM.label}> (μs)</Text>
<TextInput
style={GSM.input}
keyboardType="numeric"
value={tEndStr}
onChangeText={setTEndStr}
/>
</View>
</View>
<View style={[GSM.row, { marginTop: 6 }]}>
<View style={GSM.field}>
<Text style={GSM.label}></Text>
<TextInput
style={GSM.input}
keyboardType="numeric"
value={countStr}
onChangeText={setCountStr}
/>
</View>
<View style={[GSM.field, { justifyContent: 'flex-end' }]}>
<Text style={GSM.label}></Text>
<View style={GSM.toggle}>
{(['log', 'linear'] as GateSpacing[]).map(s => (
<TouchableOpacity
key={s}
style={[GSM.toggleBtn, spacing === s && GSM.toggleBtnOn]}
onPress={() => setSpacing(s)}
>
<Text style={[GSM.toggleTxt, spacing === s && GSM.toggleTxtOn]}>
{s === 'log' ? '对数' : '线性'}
</Text>
</TouchableOpacity>
))}
</View>
</View>
</View>
{sampleDepth > 0 && hz > 0 && (
<Text style={GSM.hint}>: {fmtUs(maxUs)}</Text>
)}
<View style={GSM.actions}>
<TouchableOpacity style={GSM.cancelBtn} onPress={onClose}>
<Text style={GSM.cancelTxt}></Text>
</TouchableOpacity>
<TouchableOpacity style={GSM.applyBtn} onPress={handleApply}>
<Text style={GSM.applyTxt}></Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
const GSM = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)' },
sheet: { backgroundColor: '#0e0e1c', borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 20, paddingBottom: 36 },
title: { color: '#8888bb', fontSize: 14, fontWeight: '700', marginBottom: 16 },
row: { flexDirection: 'row', gap: 12 },
field: { flex: 1, gap: 4 },
label: { color: '#4a4a6a', fontSize: 11, fontWeight: '600' },
input: { backgroundColor: '#111120', borderRadius: 8, borderWidth: 1, borderColor: '#2a2a3a', color: '#9090b8', fontSize: 13, paddingHorizontal: 10, paddingVertical: 8 },
toggle: { flexDirection: 'row', borderRadius: 8, overflow: 'hidden', borderWidth: 1, borderColor: '#2a2a3a' },
toggleBtn: { flex: 1, paddingVertical: 9, alignItems: 'center', backgroundColor: '#111120' },
toggleBtnOn: { backgroundColor: '#0d2040' },
toggleTxt: { color: '#4a4a6a', fontSize: 12, fontWeight: '700' },
toggleTxtOn: { color: '#4a9eff' },
hint: { color: '#2a2a4a', fontSize: 10, marginTop: 8 },
actions: { flexDirection: 'row', gap: 10, marginTop: 20 },
cancelBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1, borderColor: '#2a2a3a', alignItems: 'center' },
cancelTxt: { color: '#4a4a6a', fontSize: 13, fontWeight: '600' },
applyBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, backgroundColor: '#0d2040', borderWidth: 1, borderColor: '#4a9eff55', alignItems: 'center' },
applyTxt: { color: '#4a9eff', fontSize: 13, fontWeight: '700' },
});
// ── GatePreview ────────────────────────────────────────────────────────────
const GP = { t: 6, r: 8, b: 6, l: 8 };
function GatePreview({ refFrame, channelIdx, gatePositions, gateColors, width }: {
refFrame: LoadedFrame | null;
channelIdx: number;
gatePositions: number[];
gateColors: string[];
width: number;
}) {
const plotW = width - GP.l - GP.r;
const plotH = CANVAS_H - GP.t - GP.b;
const wavePath = useMemo(() => {
if (!refFrame) return null;
const ch = refFrame.adcUV[channelIdx];
if (!ch || ch.length === 0) return null;
const n = Math.min(ch.length, 256);
let logMin = Infinity, logMax = -Infinity;
for (let i = 0; i < n; i++) {
const a = Math.abs(ch[Math.floor(i * ch.length / n)]);
if (a > 1e-9) { const l = Math.log10(a); if (l < logMin) logMin = l; if (l > logMax) logMax = l; }
}
if (!isFinite(logMin)) return null;
const logR = Math.max(logMax - logMin, 0.5);
const path = Skia.Path.Make();
let moved = false;
for (let i = 0; i < n; i++) {
const v = ch[Math.floor(i * ch.length / n)];
const a = Math.abs(v);
if (a < 1e-12) continue;
const x = GP.l + (i / (n - 1)) * plotW;
const y = GP.t + plotH - ((Math.log10(a) - logMin) / logR) * plotH;
if (!isFinite(y)) continue;
if (!moved) { path.moveTo(x, y); moved = true; } else path.lineTo(x, y);
}
return path;
}, [refFrame, channelIdx, plotW, plotH]);
return (
<Canvas style={{ width, height: CANVAS_H }}>
{wavePath && <Path path={wavePath} color="#445566" style="stroke" strokeWidth={1} />}
{gatePositions.map((pos, gi) => (
<Line key={gi}
p1={vec(GP.l + pos * plotW, GP.t)}
p2={vec(GP.l + pos * plotW, GP.t + plotH)}
color={gateColors[gi] ?? '#fff'} strokeWidth={1.5}
/>
))}
</Canvas>
);
}
// ── GateLineChart ──────────────────────────────────────────────────────────
function GateLineChart({ frames, channelIdx, gatePositions, gateColors, logScale, width, height }: {
frames: LoadedFrame[]; channelIdx: number;
gatePositions: number[]; gateColors: string[];
logScale: boolean; width: number; height: number;
}) {
const plotW = width - CP.left - CP.right;
const plotH = height - CP.top - CP.bottom;
const derived = useMemo(() => {
const sd = frames[0]?.sampleDepth ?? 0;
const series = gatePositions.map(pos => {
if (sd === 0) return null;
const si = Math.min(sd - 1, Math.floor(pos * sd));
return frames.map(f => f.adcUV[channelIdx]?.[si] ?? 0);
});
let vMin = Infinity, vMax = -Infinity;
for (const s of series) {
if (!s) continue;
for (const v of s) {
if (Math.abs(v) < 1e-9) continue;
const val = logScale ? Math.log10(Math.abs(v)) : v;
if (val < vMin) vMin = val;
if (val > vMax) vMax = val;
}
}
if (!isFinite(vMin)) { vMin = logScale ? -3 : -1; vMax = logScale ? 6 : 1; }
const pad = (vMax - vMin) * 0.05;
const yMin = vMin - pad, yMax = vMax + pad;
const yRange = Math.max(yMax - yMin, 1e-9);
const toY = (v: number) => {
const m = logScale ? (Math.abs(v) < 1e-12 ? yMin : Math.log10(Math.abs(v))) : v;
return CP.top + plotH - ((m - yMin) / yRange) * plotH;
};
const toX = (i: number) => CP.left + (i / Math.max(frames.length - 1, 1)) * plotW;
const paths = series.map((s) => {
if (!s) return null;
const path = Skia.Path.Make();
let moved = false;
for (let i = 0; i < s.length; i++) {
if (logScale && Math.abs(s[i]) < 1e-12) continue;
const x = toX(i), y = toY(s[i]);
if (!isFinite(y)) continue;
if (!moved) { path.moveTo(x, y); moved = true; } else path.lineTo(x, y);
}
return path;
});
let yTicks: number[];
if (logScale) {
yTicks = [-1, 0, 1, 2, 3, 4, 5, 6, 7]
.filter(e => e >= yMin && e <= yMax)
.map(e => Math.pow(10, e));
} else {
const step = niceStep(yMax - yMin, 5);
yTicks = [];
for (let v = Math.ceil(yMin / step) * step; v <= yMax * 1.001; v += step) yTicks.push(v);
}
const xTicks: number[] = [];
if (frames.length > 2) {
const step = Math.max(1, Math.round(niceStep(frames.length - 1, 5)));
for (let xi = step; xi < frames.length; xi += step) xTicks.push(xi);
}
return { paths, yTicks, xTicks, toY, toX };
}, [frames, channelIdx, gatePositions, logScale, plotW, plotH]);
const { paths, yTicks, xTicks, toY, toX } = derived;
const left = CP.left, right = CP.left + plotW;
const top = CP.top, bot = CP.top + plotH;
return (
<View style={{ width, height, position: 'relative' }}>
<Canvas style={{ width, height }}>
{yTicks.map((v, idx) => {
const y = toY(v);
if (!isFinite(y) || y < top - 1 || y > bot + 1) return null;
return <Line key={`y_${idx}`} p1={vec(left, y)} p2={vec(right, y)} color="#222" strokeWidth={0.5} />;
})}
{xTicks.map((i, idx) => (
<Line key={`x_${idx}`} p1={vec(toX(i), top)} p2={vec(toX(i), bot)} color="#1e1e1e" strokeWidth={0.5} />
))}
{!logScale && isFinite(toY(0)) && (
<Line p1={vec(left, toY(0))} p2={vec(right, toY(0))} color="#444" strokeWidth={0.8} />
)}
<Line p1={vec(left, bot)} p2={vec(right, bot)} color="#555" strokeWidth={1} />
<Line p1={vec(left, top)} p2={vec(left, bot)} color="#555" strokeWidth={1} />
{paths.map((p, gi) =>
p ? <Path key={`g${gi}`} path={p} color={gateColors[gi] ?? '#fff'} style="stroke"
strokeWidth={1.5} strokeJoin="round" strokeCap="round" /> : null,
)}
</Canvas>
{yTicks.map((v, idx) => {
const y = toY(v);
if (!isFinite(y) || y < top - 1 || y > bot + 1) return null;
const label = logScale ? (v >= 1000 ? `${v / 1000}k` : `${v}`) : formatUVShort(v);
return <Text key={`yl_${idx}`} style={[LBL.y, { top: y - 7, left: 2, width: CP.left - 4 }]}>{label}</Text>;
})}
{xTicks.map((i, idx) => (
<Text key={`xl_${idx}`} style={[LBL.x, { top: height - 20, left: toX(i) - 18, width: 36 }]}>{i}</Text>
))}
<Text style={[LBL.y, { top: 2, left: 2 }]}>μV</Text>
<Text style={[LBL.x, { position: 'absolute', bottom: 2, right: 8 }]}></Text>
</View>
);
}
const LBL = StyleSheet.create({
y: { position: 'absolute', color: '#666', fontSize: 9, textAlign: 'right' },
x: { position: 'absolute', color: '#666', fontSize: 9, textAlign: 'center' },
});
// ── SessionPickerModal ─────────────────────────────────────────────────────
function SessionPickerModal({ visible, sessions, allSessions, currentId, selectedId, onSelect, onClose }: {
visible: boolean; sessions: SessionInfo[]; allSessions: SessionInfo[];
currentId: string; selectedId: string;
onSelect: (id: string) => void; onClose: () => void;
}) {
const [showAll, setShowAll] = useState(false);
const source = showAll ? allSessions : sessions;
const listData = useMemo(() => {
const currentEntry = { sessionId: currentId, createdAt: 0, frameCount: -1, projectId: null };
const rest = source.filter(s => s.sessionId !== currentId);
return [currentEntry, ...rest];
}, [source, currentId]);
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<TouchableOpacity style={SPM.backdrop} activeOpacity={1} onPress={onClose} />
<View style={SPM.sheet}>
<View style={SPM.header}>
<Text style={SPM.title}>线</Text>
<TouchableOpacity style={[SPM.allBtn, showAll && SPM.allBtnOn]} onPress={() => setShowAll(v => !v)}>
<Text style={[SPM.allTxt, showAll && SPM.allTxtOn]}>{showAll ? '全部' : '当前工程'}</Text>
</TouchableOpacity>
</View>
<FlatList
data={listData}
keyExtractor={item => item.sessionId}
style={SPM.list}
renderItem={({ item }) => {
const isCur = item.sessionId === currentId;
const isSel = item.sessionId === selectedId;
return (
<TouchableOpacity
style={[SPM.item, isSel && SPM.itemSel]}
onPress={() => { onSelect(item.sessionId); onClose(); }}
>
<Text style={SPM.itemId} numberOfLines={1}>
{item.sessionId}{isCur ? ' (当前)' : ''}
</Text>
{item.frameCount >= 0 && (
<Text style={SPM.itemCnt}>{item.frameCount} </Text>
)}
</TouchableOpacity>
);
}}
/>
</View>
</Modal>
);
}
const SPM = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.55)' },
sheet: { backgroundColor: '#0e0e1c', borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 16, maxHeight: '55%' },
header: { flexDirection: 'row', alignItems: 'center', marginBottom: 12, gap: 8 },
title: { flex: 1, color: '#6a6a8a', fontSize: 12, fontWeight: '700' },
allBtn: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 6, borderWidth: 1, borderColor: '#2a2a3a', backgroundColor: '#0e0e1c' },
allBtnOn: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' },
allTxt: { color: '#555', fontSize: 10, fontWeight: '700' },
allTxtOn: { color: '#4a9eff' },
list: { maxHeight: 280 },
item: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#1a1a2a' },
itemSel: { backgroundColor: '#111130' },
itemId: { color: '#8888aa', fontSize: 12, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', flex: 1 },
itemCnt: { color: '#4a4a6a', fontSize: 11, marginLeft: 8 },
});
// ── ProfileScreen ──────────────────────────────────────────────────────────
export default function ProfileScreen() {
const { width: sw, height: sh } = useWindowDimensions();
const { history, sessionId: currentSessionId, projectId } = useDataStore();
const sampleFreqCode = useDeviceStore(s => s.config.sampleFreq);
const gateConfig = useDeviceStore(s => s.gateConfig);
const setGateConfig = useDeviceStore(s => s.setGateConfig);
const [channelIdx, setChannelIdx] = useState(0);
const [sessionId, setSessionId] = useState(currentSessionId);
const [projectSessions, setProjectSessions] = useState<SessionInfo[]>([]);
const [allSessions, setAllSessions] = useState<SessionInfo[]>([]);
const [pickerVisible, setPickerVisible] = useState(false);
const [settingsVisible, setSettingsVisible] = useState(false);
const [logScale, setLogScale] = useState(true);
const { frames, loading, progress } = useProfileData(sessionId, currentSessionId, history);
const CHART_H = Math.round(Math.min(300, sh * 0.42));
useEffect(() => {
const load = async () => {
const all = await StorageService.listSessions();
setAllSessions(all.filter(x => x.sessionId !== currentSessionId));
if (projectId) {
const proj = await StorageService.listSessionsByProject(projectId);
setProjectSessions(proj.filter(x => x.sessionId !== currentSessionId));
} else {
setProjectSessions(all.filter(x => x.sessionId !== currentSessionId));
}
};
load();
}, [currentSessionId, projectId]);
const maxCh = frames[0]?.channelNum ?? 6;
useEffect(() => { if (channelIdx >= maxCh) setChannelIdx(0); }, [maxCh, channelIdx]);
const hz = SAMPLE_FREQ_HZ[sampleFreqCode] ?? 31250;
const sampleDepth = frames[0]?.sampleDepth ?? 0;
const gatePositions = useMemo(
() => computeGatePositions(gateConfig, sampleDepth, hz),
[gateConfig, sampleDepth, hz],
);
const gateColors = useMemo(
() => Array.from({ length: gateConfig.count }, (_, i) => gateColor(i, gateConfig.count)),
[gateConfig.count],
);
const refFrame = useMemo(() => frames[Math.floor(frames.length / 2)] ?? null, [frames]);
return (
<View style={SCR.root}>
{/* ── Header ── */}
<View style={SCR.header}>
<TouchableOpacity style={SCR.sessBtn} onPress={() => setPickerVisible(true)}>
<Text style={SCR.sessBtnTxt} numberOfLines={1}>{sessionId}</Text>
<Text style={SCR.sessBtnArrow}></Text>
</TouchableOpacity>
<TouchableOpacity style={[SCR.scaleBtn, !logScale && SCR.scaleBtnOn]} onPress={() => setLogScale(v => !v)}>
<Text style={[SCR.scaleTxt, !logScale && SCR.scaleTxtOn]}>{logScale ? 'LOG' : 'LIN'}</Text>
</TouchableOpacity>
</View>
{/* ── Channel selector ── */}
<View style={SCR.chRow}>
{Array.from({ length: maxCh }, (_, i) => (
<TouchableOpacity key={i}
style={[SCR.chBtn, channelIdx === i && {
borderColor: CHANNEL_COLORS[i] + '99',
backgroundColor: CHANNEL_COLORS[i] + '1a',
}]}
onPress={() => setChannelIdx(i)}
>
<Text style={[SCR.chTxt, channelIdx === i && { color: CHANNEL_COLORS[i] }]}>
CH{i + 1}
</Text>
</TouchableOpacity>
))}
</View>
{/* ── Loading progress ── */}
{loading && (
<View style={SCR.loadRow}>
<ActivityIndicator size="small" color="#4a9eff" />
<Text style={SCR.loadTxt}> {progress.current}/{progress.total} </Text>
</View>
)}
{/* ── Empty ── */}
{!loading && frames.length === 0 && (
<View style={SCR.empty}>
<Text style={SCR.emptyTxt}></Text>
</View>
)}
{/* ── Charts ── */}
{frames.length > 0 && (
<ScrollView style={{ flex: 1 }} showsVerticalScrollIndicator={false}>
<GateLineChart
frames={frames} channelIdx={channelIdx}
gatePositions={gatePositions} gateColors={gateColors}
logScale={logScale} width={sw} height={CHART_H}
/>
{/* Gate preview + legend + settings */}
<View style={SCR.gateSection}>
<View style={SCR.gateHeader}>
<Text style={SCR.gateHdr}>
· {gateConfig.count} · {gateConfig.spacing === 'log' ? '对数' : '线性'}
</Text>
<TouchableOpacity style={SCR.settingsBtn} onPress={() => setSettingsVisible(true)}>
<Text style={SCR.settingsTxt}> </Text>
</TouchableOpacity>
</View>
<GatePreview
refFrame={refFrame} channelIdx={channelIdx}
gatePositions={gatePositions} gateColors={gateColors}
width={sw}
/>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={SCR.legendScroll}>
<View style={SCR.legendRow}>
{gatePositions.map((pos, gi) => {
const tUs = sampleDepth > 0 && hz > 0 ? pos * (sampleDepth / hz) * 1e6 : 0;
return (
<View key={gi} style={SCR.legendItem}>
<View style={[SCR.legendDot, { backgroundColor: gateColors[gi] }]} />
<Text style={SCR.legendTxt}>G{gi + 1} {fmtUs(tUs)}</Text>
</View>
);
})}
</View>
</ScrollView>
</View>
</ScrollView>
)}
<SessionPickerModal
visible={pickerVisible}
sessions={projectSessions}
allSessions={allSessions}
currentId={currentSessionId}
selectedId={sessionId}
onSelect={setSessionId}
onClose={() => setPickerVisible(false)}
/>
<GateSettingsModal
visible={settingsVisible}
config={gateConfig}
onApply={setGateConfig}
onClose={() => setSettingsVisible(false)}
sampleDepth={sampleDepth}
hz={hz}
/>
</View>
);
}
const SCR = StyleSheet.create({
root: { flex: 1, backgroundColor: '#0d0d0d' },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, paddingVertical: 8, gap: 8 },
sessBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', backgroundColor: '#111', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, borderWidth: 1, borderColor: '#222' },
sessBtnTxt: { flex: 1, color: '#6a9eff', fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
sessBtnArrow: { color: '#555', fontSize: 10, marginLeft: 4 },
scaleBtn: { paddingHorizontal: 8, paddingVertical: 5, borderRadius: 6, borderWidth: 1, borderColor: '#333', backgroundColor: '#1a1a1a' },
scaleBtnOn: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' },
scaleTxt: { color: '#555', fontSize: 11, fontWeight: '700', letterSpacing: 0.5 },
scaleTxtOn: { color: '#4a9eff' },
chRow: { flexDirection: 'row', paddingHorizontal: 10, paddingBottom: 6, gap: 6 },
chBtn: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, borderWidth: 1, borderColor: '#2a2a2a' },
chTxt: { color: '#555', fontSize: 11, fontWeight: '600' },
loadRow: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 12 },
loadTxt: { color: '#556', fontSize: 12 },
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingTop: 80 },
emptyTxt: { color: '#333', fontSize: 14 },
gateSection: { backgroundColor: '#111', marginTop: 2 },
gateHeader: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingTop: 8, paddingBottom: 4 },
gateHdr: { flex: 1, color: '#444', fontSize: 10, fontWeight: '600', letterSpacing: 0.3 },
settingsBtn: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 6, borderWidth: 1, borderColor: '#2a3a5a', backgroundColor: '#0a1428' },
settingsTxt: { color: '#4a7abf', fontSize: 11, fontWeight: '600' },
legendScroll: { paddingHorizontal: 12, paddingVertical: 8 },
legendRow: { flexDirection: 'row', gap: 10 },
legendItem: { flexDirection: 'row', alignItems: 'center', gap: 4 },
legendDot: { width: 8, height: 8, borderRadius: 4 },
legendTxt: { color: '#668', fontSize: 10 },
});