TriloopTem_App/app/(tabs)/profile.tsx
2026-06-20 21:43:09 +08:00

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 },
});