653 lines
26 KiB
TypeScript
653 lines
26 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import {
|
|
View, Text, StyleSheet, TouchableOpacity, Modal,
|
|
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 { CHANNEL_COLORS } from '../../src/protocol/constants';
|
|
import { useTheme, type ThemeColors } from '../../src/design/tokens';
|
|
import { NoProjectGate } from '../../src/components/NoProjectGate';
|
|
import { SessionSelector } from '../../src/components/SessionSelector';
|
|
|
|
// ── 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]);
|
|
|
|
const latestFrameId = useDataStore((s) => s.currentFrame?.frameId ?? -1);
|
|
|
|
useEffect(() => {
|
|
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, latestFrameId]);
|
|
|
|
return {
|
|
frames: histFrames,
|
|
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 theme = useTheme();
|
|
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, { backgroundColor: theme.bg.surface }]}>
|
|
<Text style={[GSM.title, { color: theme.text.secondary }]}>时间门配置</Text>
|
|
|
|
<View style={GSM.row}>
|
|
<View style={GSM.field}>
|
|
<Text style={[GSM.label, { color: theme.text.muted }]}>起始时间 (μs)</Text>
|
|
<TextInput
|
|
style={[GSM.input, {
|
|
backgroundColor: theme.bg.raised,
|
|
borderColor: theme.bg.border,
|
|
color: theme.text.secondary,
|
|
}]}
|
|
keyboardType="numeric"
|
|
value={tStartStr}
|
|
onChangeText={setTStartStr}
|
|
/>
|
|
</View>
|
|
<View style={GSM.field}>
|
|
<Text style={[GSM.label, { color: theme.text.muted }]}>终止时间 (μs)</Text>
|
|
<TextInput
|
|
style={[GSM.input, {
|
|
backgroundColor: theme.bg.raised,
|
|
borderColor: theme.bg.border,
|
|
color: theme.text.secondary,
|
|
}]}
|
|
keyboardType="numeric"
|
|
value={tEndStr}
|
|
onChangeText={setTEndStr}
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={[GSM.row, { marginTop: 6 }]}>
|
|
<View style={GSM.field}>
|
|
<Text style={[GSM.label, { color: theme.text.muted }]}>门数量</Text>
|
|
<TextInput
|
|
style={[GSM.input, {
|
|
backgroundColor: theme.bg.raised,
|
|
borderColor: theme.bg.border,
|
|
color: theme.text.secondary,
|
|
}]}
|
|
keyboardType="numeric"
|
|
value={countStr}
|
|
onChangeText={setCountStr}
|
|
/>
|
|
</View>
|
|
<View style={[GSM.field, { justifyContent: 'flex-end' }]}>
|
|
<Text style={[GSM.label, { color: theme.text.muted }]}>间隔方式</Text>
|
|
<View style={[GSM.toggle, { borderColor: theme.bg.border }]}>
|
|
{(['log', 'linear'] as GateSpacing[]).map(s => (
|
|
<TouchableOpacity
|
|
key={s}
|
|
style={[GSM.toggleBtn,
|
|
{ backgroundColor: theme.bg.raised },
|
|
spacing === s && { backgroundColor: theme.blue.bg },
|
|
]}
|
|
onPress={() => setSpacing(s)}
|
|
>
|
|
<Text style={[GSM.toggleTxt,
|
|
{ color: theme.text.muted },
|
|
spacing === s && { color: theme.blue.fg },
|
|
]}>
|
|
{s === 'log' ? '对数' : '线性'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{sampleDepth > 0 && hz > 0 && (
|
|
<Text style={[GSM.hint, { color: theme.text.ghost }]}>当前采样窗口: {fmtUs(maxUs)}</Text>
|
|
)}
|
|
|
|
<View style={GSM.actions}>
|
|
<TouchableOpacity style={[GSM.cancelBtn, { borderColor: theme.bg.border }]} onPress={onClose}>
|
|
<Text style={[GSM.cancelTxt, { color: theme.text.muted }]}>取消</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity style={[GSM.applyBtn, {
|
|
backgroundColor: theme.blue.bg,
|
|
borderColor: theme.blue.fg + '55',
|
|
}]} onPress={handleApply}>
|
|
<Text style={[GSM.applyTxt, { color: theme.blue.fg }]}>应用</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
const GSM = StyleSheet.create({
|
|
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)' },
|
|
sheet: { borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 20, paddingBottom: 36 },
|
|
title: { fontSize: 14, fontWeight: '700', marginBottom: 16 },
|
|
row: { flexDirection: 'row', gap: 12 },
|
|
field: { flex: 1, gap: 4 },
|
|
label: { fontSize: 11, fontWeight: '600' },
|
|
input: { borderRadius: 8, borderWidth: 1, fontSize: 13, paddingHorizontal: 10, paddingVertical: 8 },
|
|
toggle: { flexDirection: 'row', borderRadius: 8, overflow: 'hidden', borderWidth: 1 },
|
|
toggleBtn: { flex: 1, paddingVertical: 9, alignItems: 'center' },
|
|
toggleTxt: { fontSize: 12, fontWeight: '700' },
|
|
hint: { fontSize: 10, marginTop: 8 },
|
|
actions: { flexDirection: 'row', gap: 10, marginTop: 20 },
|
|
cancelBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1, alignItems: 'center' },
|
|
cancelTxt: { fontSize: 13, fontWeight: '600' },
|
|
applyBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1, alignItems: 'center' },
|
|
applyTxt: { 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 theme = useTheme();
|
|
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={theme.chart.grid} 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 theme = useTheme();
|
|
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={theme.chart.gridFine} strokeWidth={0.5} />;
|
|
})}
|
|
{xTicks.map((i, idx) => (
|
|
<Line key={`x_${idx}`} p1={vec(toX(i), top)} p2={vec(toX(i), bot)} color={theme.chart.gridFine} strokeWidth={0.5} />
|
|
))}
|
|
{!logScale && isFinite(toY(0)) && (
|
|
<Line p1={vec(left, toY(0))} p2={vec(right, toY(0))} color={theme.chart.axis} strokeWidth={0.8} />
|
|
)}
|
|
<Line p1={vec(left, bot)} p2={vec(right, bot)} color={theme.chart.axis} strokeWidth={1} />
|
|
<Line p1={vec(left, top)} p2={vec(left, bot)} color={theme.chart.axis} 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, color: theme.chart.label }]}>{label}</Text>;
|
|
})}
|
|
{xTicks.map((i, idx) => (
|
|
<Text key={`xl_${idx}`} style={[LBL.x, { top: height - 20, left: toX(i) - 18, width: 36, color: theme.chart.label }]}>{i}</Text>
|
|
))}
|
|
<Text style={[LBL.y, { top: 2, left: 2, color: theme.chart.label }]}>μV</Text>
|
|
<Text style={[LBL.x, { position: 'absolute', bottom: 2, right: 8, color: theme.chart.label }]}>帧</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const LBL = StyleSheet.create({
|
|
y: { position: 'absolute', fontSize: 9, textAlign: 'right' },
|
|
x: { position: 'absolute', fontSize: 9, textAlign: 'center' },
|
|
});
|
|
|
|
// ── ProfileScreen ──────────────────────────────────────────────────────────
|
|
|
|
export default function ProfileScreen() {
|
|
const theme = useTheme();
|
|
const { width: sw, height: sh } = useWindowDimensions();
|
|
const { history, sessionId, projectId, hasProject, resumeSession } = 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 [settingsVisible, setSettingsVisible] = useState(false);
|
|
const [logScale, setLogScale] = useState(false);
|
|
|
|
const { frames, loading, progress } = useProfileData(sessionId, sessionId, history);
|
|
const CHART_H = Math.round(Math.min(300, sh * 0.42));
|
|
|
|
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]);
|
|
|
|
const handleSessionChange = async (newSessionId: string) => {
|
|
await resumeSession(newSessionId, projectId!);
|
|
};
|
|
|
|
// ── NoProjectGate ──
|
|
if (!hasProject) {
|
|
return (
|
|
<View style={[SCR.root, { backgroundColor: theme.bg.void }]}>
|
|
<NoProjectGate />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={[SCR.root, { backgroundColor: theme.bg.void }]}>
|
|
|
|
{/* ── Header ── */}
|
|
<View style={SCR.header}>
|
|
<View style={{ flex: 1 }}>
|
|
<SessionSelector
|
|
selectedSessionId={sessionId}
|
|
projectId={projectId!}
|
|
onSelect={handleSessionChange}
|
|
/>
|
|
</View>
|
|
<TouchableOpacity
|
|
style={[SCR.scaleBtn, {
|
|
borderColor: theme.bg.border,
|
|
backgroundColor: theme.bg.base,
|
|
}, !logScale && {
|
|
borderColor: theme.blue.fg + '55',
|
|
backgroundColor: theme.blue.bg,
|
|
}]}
|
|
onPress={() => setLogScale(v => !v)}
|
|
>
|
|
<Text style={[SCR.scaleTxt, { color: theme.chart.axis }, !logScale && { color: theme.blue.fg }]}>
|
|
{logScale ? 'LOG' : 'LIN'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* ── Channel selector ── */}
|
|
<View style={SCR.chRow}>
|
|
{Array.from({ length: maxCh }, (_, i) => (
|
|
<TouchableOpacity key={i}
|
|
style={[SCR.chBtn, { borderColor: theme.bg.border }, channelIdx === i && {
|
|
borderColor: CHANNEL_COLORS[i] + '99',
|
|
backgroundColor: CHANNEL_COLORS[i] + '1a',
|
|
}]}
|
|
onPress={() => setChannelIdx(i)}
|
|
>
|
|
<Text style={[SCR.chTxt, { color: theme.chart.axis }, channelIdx === i && { color: CHANNEL_COLORS[i] }]}>
|
|
CH{i + 1}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
|
|
{/* ── Loading progress ── */}
|
|
{loading && (
|
|
<View style={SCR.loadRow}>
|
|
<ActivityIndicator size="small" color={theme.blue.fg} />
|
|
<Text style={[SCR.loadTxt, { color: theme.text.muted }]}>加载 {progress.current}/{progress.total} 帧…</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* ── Empty ── */}
|
|
{!loading && frames.length === 0 && (
|
|
<View style={SCR.empty}>
|
|
<Text style={[SCR.emptyTxt, { color: theme.text.ghost }]}>暂无剖面数据</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, { backgroundColor: theme.bg.base }]}>
|
|
<View style={SCR.gateHeader}>
|
|
<Text style={[SCR.gateHdr, { color: theme.chart.badge }]}>
|
|
时间门预览 · {gateConfig.count} 门 · {gateConfig.spacing === 'log' ? '对数' : '线性'}间隔
|
|
</Text>
|
|
<TouchableOpacity style={[SCR.settingsBtn, {
|
|
borderColor: theme.blue.border,
|
|
backgroundColor: theme.blue.bg,
|
|
}]} onPress={() => setSettingsVisible(true)}>
|
|
<Text style={[SCR.settingsTxt, { color: theme.blue.fg }]}>⚙ 配置</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, { color: theme.text.muted }]}>G{gi + 1} {fmtUs(tUs)}</Text>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
</ScrollView>
|
|
</View>
|
|
|
|
</ScrollView>
|
|
)}
|
|
|
|
<GateSettingsModal
|
|
visible={settingsVisible}
|
|
config={gateConfig}
|
|
onApply={setGateConfig}
|
|
onClose={() => setSettingsVisible(false)}
|
|
sampleDepth={sampleDepth}
|
|
hz={hz}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const SCR = StyleSheet.create({
|
|
root: { flex: 1 },
|
|
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, paddingVertical: 8, gap: 8 },
|
|
scaleBtn: { paddingHorizontal: 8, paddingVertical: 5, borderRadius: 6, borderWidth: 1 },
|
|
scaleTxt: { fontSize: 11, fontWeight: '700', letterSpacing: 0.5 },
|
|
chRow: { flexDirection: 'row', paddingHorizontal: 10, paddingBottom: 6, gap: 6 },
|
|
chBtn: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, borderWidth: 1 },
|
|
chTxt: { fontSize: 11, fontWeight: '600' },
|
|
loadRow: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 12 },
|
|
loadTxt: { fontSize: 12 },
|
|
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingTop: 80 },
|
|
emptyTxt: { fontSize: 14 },
|
|
|
|
gateSection: { marginTop: 2 },
|
|
gateHeader: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingTop: 8, paddingBottom: 4 },
|
|
gateHdr: { flex: 1, fontSize: 10, fontWeight: '600', letterSpacing: 0.3 },
|
|
settingsBtn: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 6, borderWidth: 1 },
|
|
settingsTxt: { 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: { fontSize: 10 },
|
|
});
|