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 = { 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([]); const [loading, setLoading] = useState(false); const [progress, setProgress] = useState({ current: 0, total: 0 }); const liveFrames = useMemo(() => { 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(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 ( 时间门配置 起始时间 (μs) 终止时间 (μs) 门数量 间隔方式 {(['log', 'linear'] as GateSpacing[]).map(s => ( setSpacing(s)} > {s === 'log' ? '对数' : '线性'} ))} {sampleDepth > 0 && hz > 0 && ( 当前采样窗口: {fmtUs(maxUs)} )} 取消 应用 ); } 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 ( {wavePath && } {gatePositions.map((pos, gi) => ( ))} ); } // ── 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 ( {yTicks.map((v, idx) => { const y = toY(v); if (!isFinite(y) || y < top - 1 || y > bot + 1) return null; return ; })} {xTicks.map((i, idx) => ( ))} {!logScale && isFinite(toY(0)) && ( )} {paths.map((p, gi) => p ? : null, )} {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 {label}; })} {xTicks.map((i, idx) => ( {i} ))} μV ); } 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 ( ); } return ( {/* ── Header ── */} setLogScale(v => !v)} > {logScale ? 'LOG' : 'LIN'} {/* ── Channel selector ── */} {Array.from({ length: maxCh }, (_, i) => ( setChannelIdx(i)} > CH{i + 1} ))} {/* ── Loading progress ── */} {loading && ( 加载 {progress.current}/{progress.total} 帧… )} {/* ── Empty ── */} {!loading && frames.length === 0 && ( 暂无剖面数据 )} {/* ── Charts ── */} {frames.length > 0 && ( {/* Gate preview + legend + settings */} 时间门预览 · {gateConfig.count} 门 · {gateConfig.spacing === 'log' ? '对数' : '线性'}间隔 setSettingsVisible(true)}> ⚙ 配置 {gatePositions.map((pos, gi) => { const tUs = sampleDepth > 0 && hz > 0 ? pos * (sampleDepth / hz) * 1e6 : 0; return ( G{gi + 1} {fmtUs(tUs)} ); })} )} setSettingsVisible(false)} sampleDepth={sampleDepth} hz={hz} /> ); } 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 }, });