699 lines
29 KiB
TypeScript
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 },
|
|
});
|