Commit 641c56ec authored by Cloud's avatar Cloud

feat(SimResults): 增强图表交互功能

- 添加中键拖拽平移(按住滚轮拖动,带⇔视觉指示器)
- 添加滚轮缩放、左键框选放大
- 添加空格键智能裁剪(自动聚焦到数据变化区域)
- 添加工具栏:放大/缩小/撤销/重置/导出PNG
- 变量名自动翻译为中文标签(如 capacitor_1.p.v → 电容1 正极电压(V))
- 修复 React Hooks 顺序违规导致的白屏问题
- 使用 rAF 节流优化平移性能
parent 3a68f372
/**
* SimResultsModal - 仿真结果图表查看器(弹窗模式)
* 解析 CSV 数据,以 Recharts 折线图展示变量随时间变化
*
* 参考:lcr_oscillator/results/index.html 的 Chart.js 做法
* 改用 Recharts (React 生态) 实现等效功能
* 交互:
* - 左键拖拽框选放大
* - 滚轮缩放 X 轴
* - 按住滚轮(中键)拖拽平移
* - 空格键自适应(重置)
* - 工具栏: 放大/缩小/撤销/重置/导出 PNG
* - 变量名自动中文翻译
*/
import { useState, useMemo, useCallback } from 'react';
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, Legend, ResponsiveContainer, Brush,
ReferenceArea,
} from 'recharts';
import styles from './SimResultsModal.module.css';
/** 预设调色板 — 参考 lcr_oscillator 的 accent 色系 */
const COLORS = [
'#4f8aff', '#22c55e', '#f59e0b', '#ef4444', '#00d4ff',
'#a855f7', '#ec4899', '#14b8a6', '#f97316', '#64748b',
'#84cc16', '#e879f9', '#2dd4bf', '#fb923c', '#6366f1',
];
/** 解析 CSV 字符串 → [{time, var1, var2, ...}, ...] */
// ===== 中文标签映射 =====
const COMP_CN = {
capacitor: '电容', resistor: '电阻', inductor: '电感',
voltagesource: '电压源', currentsource: '电流源', ground: '接地',
diode: '二极管', switch: '开关', transformer: '变压器', opamp: '运放', source: '电源',
};
const PORT_CN = { p: '正极', n: '负极' };
const QTY_CN = { v: '电压(V)', i: '电流(A)' };
function toChinese(name) {
if (!name) return name;
if (name.toLowerCase() === 'time') return '时间(s)';
let pfx = '', inner = name;
const dm = name.match(/^der\((.+)\)$/);
if (dm) { pfx = 'd/dt '; inner = dm[1]; }
const parts = inner.split('.');
const cm = parts[0].match(/^(.+?)_(\d+)$/);
let cn = parts[0], num = '';
if (cm) { cn = COMP_CN[cm[1].toLowerCase()] || cm[1]; num = cm[2]; }
else { cn = COMP_CN[parts[0].toLowerCase()] || parts[0]; }
if (parts.length === 1) return pfx + cn + num;
if (parts.length === 2) {
const s = parts[1];
return pfx + cn + num + ' ' + (QTY_CN[s] || PORT_CN[s] || s);
}
return pfx + cn + num + ' ' + (PORT_CN[parts[1]] || parts[1]) + (QTY_CN[parts[2]] || parts[2]);
}
// ===== CSV 解析 =====
function parseCSV(csv) {
if (!csv || typeof csv !== 'string') return { headers: [], data: [] };
const lines = csv.trim().split('\n').filter(l => l.trim());
if (lines.length < 2) return { headers: [], data: [] };
// 首行为 header,可能带引号
const headers = lines[0].split(',').map(h => h.replace(/^"|"$/g, '').trim());
const data = [];
for (let i = 1; i < lines.length; i++) {
const vals = lines[i].split(',');
if (vals.length !== headers.length) continue;
const row = {};
let valid = true;
let ok = true;
for (let j = 0; j < headers.length; j++) {
const num = parseFloat(vals[j]);
if (isNaN(num)) { valid = false; break; }
row[headers[j]] = num;
const n = parseFloat(vals[j]);
if (isNaN(n)) { ok = false; break; }
row[headers[j]] = n;
}
if (valid) data.push(row);
if (ok) data.push(row);
}
return { headers, data };
}
/** 自定义 Tooltip — 暗色主题 */
// ===== Tooltip =====
function CustomTooltip({ active, payload, label }) {
if (!active || !payload || payload.length === 0) return null;
if (!active || !payload?.length) return null;
return (
<div style={{
background: '#1a1a2e',
border: '1px solid rgba(79,138,255,0.2)',
borderRadius: 10,
padding: '10px 14px',
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
background: '#1a1a2e', border: '1px solid rgba(79,138,255,0.2)',
borderRadius: 10, padding: '10px 14px', boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
}}>
<div style={{ fontSize: 11, color: '#9ca3af', marginBottom: 6 }}>
time = {typeof label === 'number' ? label.toFixed(6) : label}
时间 = {typeof label === 'number' ? label.toFixed(6) : label} s
</div>
{payload.map((entry, i) => (
<div key={i} style={{ fontSize: 12, color: entry.color, marginBottom: 2 }}>
{entry.name}: <strong>{typeof entry.value === 'number' ? entry.value.toFixed(6) : entry.value}</strong>
{payload.map((e, i) => (
<div key={i} style={{ fontSize: 12, color: e.color, marginBottom: 2 }}>
{e.name}: <strong>{typeof e.value === 'number' ? e.value.toFixed(6) : e.value}</strong>
</div>
))}
</div>
);
}
/**
* @param {{csvData: string, modelName: string, onClose: () => void}} props
*/
function ToolBtn({ icon, label, onClick, disabled }) {
return (
<button className={styles.toolBtn} onClick={onClick} disabled={disabled} title={label}>
<span className={styles.toolIcon}>{icon}</span>
<span className={styles.toolLabel}>{label}</span>
</button>
);
}
// ===== 主组件 =====
export default function SimResultsModal({ csvData, modelName, onClose }) {
// --- state ---
const [selectedVars, setSelectedVars] = useState(null);
const [xDomain, setXDomain] = useState(null);
const [yDomain, setYDomain] = useState(null);
const [refAreaLeft, setRefAreaLeft] = useState(null);
const [refAreaRight, setRefAreaRight] = useState(null);
const [isDragging, setIsDragging] = useState(false);
const [zoomStack, setZoomStack] = useState([]);
// --- refs ---
const chartWrapRef = useRef(null);
const panRef = useRef({ active: false, startX: 0, startDomain: null });
const rafRef = useRef(null);
const xDomainRef = useRef(null);
const fullXRangeRef = useRef([0, 1]);
// --- derived data (all hooks before any conditional return) ---
const { headers, data } = useMemo(() => parseCSV(csvData), [csvData]);
const xKey = useMemo(() => headers.find(h => h.toLowerCase() === 'time') || headers[0] || 'time', [headers]);
const variables = useMemo(() => headers.filter(h => h !== xKey), [headers, xKey]);
// X 轴 key(通常是 time)
const xKey = useMemo(() => {
return headers.find(h => h.toLowerCase() === 'time') || headers[0] || 'time';
}, [headers]);
// 变量列表(排除 time)
const variables = useMemo(() => {
return headers.filter(h => h !== xKey);
}, [headers, xKey]);
// 初始化:默认选中前 5 个
if (selectedVars === null && variables.length > 0) {
const initial = new Set(variables.slice(0, Math.min(5, variables.length)));
// 使用同步设定避免闪烁
setSelectedVars(initial);
return null; // 重渲染一次
}
useEffect(() => {
if (variables.length > 0 && selectedVars === null) {
setSelectedVars(new Set(variables.slice(0, Math.min(5, variables.length))));
}
}, [variables]); // eslint-disable-line react-hooks/exhaustive-deps
const selected = selectedVars || new Set();
const selectedArr = [...selected];
const selectedArr = useMemo(() => [...selected], [selected]);
const fullXRange = useMemo(() => {
if (data.length === 0) return [0, 1];
return [data[0][xKey], data[data.length - 1][xKey]];
}, [data, xKey]);
const autoYDomain = useMemo(() => {
const src = xDomain ? data.filter(d => d[xKey] >= xDomain[0] && d[xKey] <= xDomain[1]) : data;
if (!selectedArr.length || !src.length) return undefined;
let lo = Infinity, hi = -Infinity;
for (const row of src) for (const v of selectedArr) {
if (row[v] !== undefined) { if (row[v] < lo) lo = row[v]; if (row[v] > hi) hi = row[v]; }
}
if (!isFinite(lo)) return undefined;
const pad = (hi - lo) * 0.1 || 1;
return [lo - pad, hi + pad];
}, [data, xDomain, xKey, selectedArr]);
const effectiveYDomain = yDomain || autoYDomain;
// --- sync refs (after useMemo definitions) ---
xDomainRef.current = xDomain;
fullXRangeRef.current = fullXRange;
// --- 中键拖拽平移 (使用直接 DOM 操作管理图标,绕过 React) ---
const panIconRef = useRef(null); // DOM 元素引用
useEffect(() => {
const el = chartWrapRef.current;
if (!el) return;
// 创建图标 DOM 元素(不走 React 渲染)
const createIcon = (x, y) => {
if (panIconRef.current) panIconRef.current.remove();
const icon = document.createElement('div');
icon.style.cssText = `
position:fixed; left:${x - 16}px; top:${y - 16}px;
width:32px; height:32px; border-radius:50%;
background:rgba(79,138,255,0.3); border:2px solid rgba(79,138,255,0.7);
display:flex; align-items:center; justify-content:center;
font-size:16px; color:#4f8aff; pointer-events:none; z-index:99999;
box-shadow:0 0 12px rgba(79,138,255,0.4);
`;
icon.textContent = '⇔';
document.body.appendChild(icon);
panIconRef.current = icon;
};
const moveIcon = (x, y) => {
if (panIconRef.current) {
panIconRef.current.style.left = (x - 16) + 'px';
panIconRef.current.style.top = (y - 16) + 'px';
}
};
const removeIcon = () => {
if (panIconRef.current) { panIconRef.current.remove(); panIconRef.current = null; }
};
const onDown = (e) => {
if (e.button !== 1 || !el.contains(e.target)) return;
e.preventDefault();
panRef.current = { active: true, startX: e.clientX, startDomain: xDomainRef.current || fullXRangeRef.current };
el.style.cursor = 'grabbing';
createIcon(e.clientX, e.clientY);
};
const toggleVar = (varName) => {
setSelectedVars(prev => {
const next = new Set(prev);
if (next.has(varName)) next.delete(varName);
else next.add(varName);
return next;
});
};
const onMove = (e) => {
if (!panRef.current.active) return;
// 移动图标跟随鼠标
moveIcon(e.clientX, e.clientY);
// rAF 节流更新图表
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
const { startX, startDomain } = panRef.current;
const fxr = fullXRangeRef.current;
const range = startDomain[1] - startDomain[0];
const dx = e.clientX - startX;
const shift = -(dx * range) / (el.clientWidth || 600);
let l = startDomain[0] + shift, r = startDomain[1] + shift;
if (l < fxr[0]) { l = fxr[0]; r = l + range; }
if (r > fxr[1]) { r = fxr[1]; l = r - range; }
setXDomain([l, r]);
setYDomain(null);
});
};
const selectAll = () => setSelectedVars(new Set(variables));
const selectNone = () => setSelectedVars(new Set());
const onUp = (e) => {
if (e.button === 1 && panRef.current.active) {
panRef.current.active = false;
el.style.cursor = '';
removeIcon();
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; }
}
};
// 数据质量检测
const hasValidData = data.length > 0 && variables.length > 0;
const onCtx = (e) => { if (el.contains(e.target)) e.preventDefault(); };
// 挂在 el 上用 capture 拦截中键,不用 stopImmediatePropagation
el.addEventListener('mousedown', onDown, true);
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
el.addEventListener('contextmenu', onCtx);
return () => {
el.removeEventListener('mousedown', onDown, true);
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
el.removeEventListener('contextmenu', onCtx);
removeIcon();
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [selectedVars]); // 初始化完成后重新绑定
// --- 空格智能裁剪: 只显示数据有变化的部分 ---
const smartFit = useCallback(() => {
if (!data.length || !selectedArr.length) return;
// 找到数据开始变化和结束变化的时间点
let firstChangeIdx = data.length - 1;
let lastChangeIdx = 0;
for (const varName of selectedArr) {
const baseVal = data[0][varName];
if (baseVal === undefined) continue;
for (let i = 1; i < data.length; i++) {
const val = data[i][varName];
if (Math.abs(val - baseVal) > Math.abs(baseVal) * 0.001 + 1e-10) {
firstChangeIdx = Math.min(firstChangeIdx, Math.max(0, i - 1));
break;
}
}
// 从后往前找最后变化点
const endVal = data[data.length - 1][varName];
for (let i = data.length - 2; i >= 0; i--) {
const val = data[i][varName];
if (Math.abs(val - endVal) > Math.abs(endVal) * 0.001 + 1e-10) {
lastChangeIdx = Math.max(lastChangeIdx, Math.min(data.length - 1, i + 1));
break;
}
}
}
if (firstChangeIdx >= lastChangeIdx) {
// 没有变化或整段都在变化,显示全部
setXDomain(null); setYDomain(null);
} else {
// 留一点 padding
const tStart = data[firstChangeIdx][xKey];
const tEnd = data[lastChangeIdx][xKey];
const pad = (tEnd - tStart) * 0.05;
setZoomStack(prev => [...prev, { xDomain, yDomain }]);
setXDomain([Math.max(fullXRange[0], tStart - pad), Math.min(fullXRange[1], tEnd + pad)]);
setYDomain(null);
}
}, [data, selectedArr, xKey, fullXRange, xDomain, yDomain]);
// 键盘监听需要使用 ref 保持最新 smartFit 引用
const smartFitRef = useRef(smartFit);
smartFitRef.current = smartFit;
useEffect(() => {
const onKey = (e) => {
const tag = e.target?.tagName?.toLowerCase();
if (e.code === 'Space' && tag !== 'input' && tag !== 'textarea') {
e.preventDefault();
smartFitRef.current();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
// --- Recharts 框选 (仅左键,中键被 document capture 拦截) ---
const handleMouseDown = useCallback((e) => {
if (panRef.current.active) return; // 中键平移中,忽略
if (e?.activeLabel != null) { setRefAreaLeft(e.activeLabel); setIsDragging(true); }
}, []);
const handleMouseMove = useCallback((e) => {
if (isDragging && e?.activeLabel != null) setRefAreaRight(e.activeLabel);
}, [isDragging]);
const handleMouseUp = useCallback(() => {
if (!isDragging) return;
setIsDragging(false);
if (refAreaLeft != null && refAreaRight != null && refAreaLeft !== refAreaRight) {
const l = Math.min(refAreaLeft, refAreaRight), r = Math.max(refAreaLeft, refAreaRight);
setZoomStack(prev => [...prev, { xDomain, yDomain }]);
setXDomain([l, r]); setYDomain(null);
}
setRefAreaLeft(null); setRefAreaRight(null);
}, [isDragging, refAreaLeft, refAreaRight, xDomain, yDomain]);
// --- 滚轮缩放 ---
const handleWheel = useCallback((e) => {
e.preventDefault();
const cur = xDomainRef.current || fullXRangeRef.current;
const range = cur[1] - cur[0];
const fxr = fullXRangeRef.current;
const factor = e.deltaY > 0 ? 1.3 : 0.7;
const mid = (cur[0] + cur[1]) / 2;
const nr = range * factor;
const l = Math.max(fxr[0], mid - nr / 2), r = Math.min(fxr[1], mid + nr / 2);
if (r - l > (fxr[1] - fxr[0]) * 0.99) { setXDomain(null); setYDomain(null); }
else { setXDomain([l, r]); setYDomain(null); }
}, []);
// --- 工具栏 ---
const zoomIn = useCallback(() => {
const cur = xDomainRef.current || fullXRangeRef.current;
const range = cur[1] - cur[0], mid = (cur[0] + cur[1]) / 2, nr = range * 0.5;
setZoomStack(prev => [...prev, { xDomain: xDomainRef.current, yDomain }]);
setXDomain([mid - nr / 2, mid + nr / 2]); setYDomain(null);
}, [yDomain]);
const zoomOut = useCallback(() => {
const cur = xDomainRef.current || fullXRangeRef.current;
const fxr = fullXRangeRef.current;
const range = cur[1] - cur[0], mid = (cur[0] + cur[1]) / 2, nr = range * 2;
const l = Math.max(fxr[0], mid - nr / 2), r = Math.min(fxr[1], mid + nr / 2);
if (r - l >= (fxr[1] - fxr[0]) * 0.99) { setXDomain(null); setYDomain(null); }
else { setZoomStack(prev => [...prev, { xDomain: xDomainRef.current, yDomain }]); setXDomain([l, r]); setYDomain(null); }
}, [yDomain]);
const resetZoom = useCallback(() => { setXDomain(null); setYDomain(null); setZoomStack([]); }, []);
const undoZoom = useCallback(() => {
if (!zoomStack.length) return;
const prev = zoomStack[zoomStack.length - 1];
setZoomStack(s => s.slice(0, -1));
setXDomain(prev.xDomain); setYDomain(prev.yDomain);
}, [zoomStack]);
const exportPNG = useCallback(() => {
const svg = chartWrapRef.current?.querySelector('svg');
if (!svg) return;
const xml = new XMLSerializer().serializeToString(svg);
const canvas = document.createElement('canvas');
const rect = svg.getBoundingClientRect();
canvas.width = rect.width * 2; canvas.height = rect.height * 2;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#0f0f1a'; ctx.fillRect(0, 0, canvas.width, canvas.height);
const img = new Image();
img.onload = () => {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const a = document.createElement('a');
a.download = `${modelName || 'SimResult'}_chart.png`;
a.href = canvas.toDataURL('image/png'); a.click();
};
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(xml)));
}, [modelName]);
const toggleVar = useCallback((v) => setSelectedVars(prev => {
const s = new Set(prev); s.has(v) ? s.delete(v) : s.add(v); return s;
}), []);
const selectAll = useCallback(() => setSelectedVars(new Set(variables)), [variables]);
const selectNone = useCallback(() => setSelectedVars(new Set()), []);
// ===== 条件渲染区 (所有 hooks 已定义) =====
const hasData = data.length > 0 && variables.length > 0;
const isZoomed = xDomain !== null;
if (hasData && selectedVars === null) return null; // 等待 useEffect 初始化
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
{/* 头部 */}
<div className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.headerIcon}>📊</span>
......@@ -127,42 +407,38 @@ export default function SimResultsModal({ csvData, modelName, onClose }) {
<button className={styles.closeBtn} onClick={onClose}></button>
</div>
{!hasValidData ? (
{!hasData ? (
<div className={styles.errorState}>
<div className={styles.errorIcon}>⚠️</div>
<div className={styles.errorText}>无法解析仿真数据</div>
<div className={styles.errorHint}>
CSV 数据格式无效或为空。请检查后端输出是否为标准 CSV 格式。
</div>
<div className={styles.errorHint}>CSV 数据格式无效或为空。请检查后端输出是否为标准 CSV 格式。</div>
</div>
) : (
<div className={styles.body}>
{/* 统计卡片 */}
<div className={styles.statsGrid}>
<div className={styles.statCard}>
<div className={styles.statLabel}>数据点</div>
<div className={styles.statValue}>{data.length}</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>变量数</div>
<div className={styles.statValue}>{variables.length}</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>已选</div>
<div className={styles.statValue}>{selectedArr.length}</div>
</div>
<div className={styles.statCard}><div className={styles.statLabel}>数据点</div><div className={styles.statValue}>{data.length}</div></div>
<div className={styles.statCard}><div className={styles.statLabel}>变量数</div><div className={styles.statValue}>{variables.length}</div></div>
<div className={styles.statCard}><div className={styles.statLabel}>已选</div><div className={styles.statValue}>{selectedArr.length}</div></div>
{data.length > 0 && (
<div className={styles.statCard}>
<div className={styles.statLabel}>时间范围</div>
<div className={styles.statValue}>
{data[0][xKey]?.toFixed(3)} ~ {data[data.length - 1][xKey]?.toFixed(3)}
</div>
<div className={styles.statValue}>{data[0][xKey]?.toFixed(3)} ~ {data[data.length - 1][xKey]?.toFixed(3)}</div>
</div>
)}
</div>
<div className={styles.toolbar}>
<ToolBtn icon="🔍+" label="放大" onClick={zoomIn} />
<ToolBtn icon="🔍−" label="缩小" onClick={zoomOut} />
<ToolBtn icon="↩" label="撤销" onClick={undoZoom} disabled={!zoomStack.length} />
<ToolBtn icon="⟲" label="重置" onClick={resetZoom} disabled={!isZoomed} />
<div className={styles.toolSep} />
<ToolBtn icon="📷" label="导出PNG" onClick={exportPNG} />
{isZoomed && <div className={styles.zoomBadge}>已缩放: {xDomain[0].toFixed(4)} ~ {xDomain[1].toFixed(4)}</div>}
<div className={styles.toolTip}>💡 拖拽框选放大 · 滚轮缩放 · 按住滚轮拖动 · 空格智能裁剪</div>
</div>
<div className={styles.content}>
{/* 左侧变量面板 */}
<div className={styles.varPanel}>
<div className={styles.varHeader}>
<span className={styles.varTitle}>变量</span>
......@@ -177,63 +453,48 @@ export default function SimResultsModal({ csvData, modelName, onClose }) {
const checked = selected.has(v);
return (
<div key={v} className={styles.varItem} onClick={() => toggleVar(v)}>
<div
className={`${styles.varCheck} ${checked ? styles.checked : ''}`}
style={checked ? { background: color, borderColor: color } : {}}
/>
<div className={`${styles.varCheck} ${checked ? styles.checked : ''}`}
style={checked ? { background: color, borderColor: color } : {}} />
<span className={styles.varDot} style={{ background: color }} />
<span className={styles.varName} title={v}>{v}</span>
<span className={styles.varName} title={v}>{toChinese(v)}</span>
</div>
);
})}
</div>
</div>
{/* 图表区域 */}
<div className={styles.chartArea}>
{selectedArr.length === 0 ? (
<div className={styles.chartArea} ref={chartWrapRef} onWheel={handleWheel}>
{!selectedArr.length ? (
<div className={styles.emptyChart}>
<div style={{ fontSize: 40, opacity: 0.3 }}>📈</div>
<div>请在左侧选择要显示的变量</div>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 10, right: 30, left: 10, bottom: 10 }}>
<LineChart data={data} margin={{ top: 10, right: 30, left: 10, bottom: 10 }}
onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(79,138,255,0.1)" />
<XAxis
dataKey={xKey}
stroke="#555"
tick={{ fill: '#9ca3af', fontSize: 10 }}
<XAxis dataKey={xKey} stroke="#555" tick={{ fill: '#9ca3af', fontSize: 10 }}
tickFormatter={v => typeof v === 'number' ? v.toFixed(3) : v}
/>
<YAxis
stroke="#555"
tick={{ fill: '#9ca3af', fontSize: 10 }}
type="number" domain={xDomain || ['dataMin', 'dataMax']} allowDataOverflow />
<YAxis stroke="#555" tick={{ fill: '#9ca3af', fontSize: 10 }}
tickFormatter={v => typeof v === 'number' ? v.toFixed(2) : v}
/>
domain={effectiveYDomain || ['auto', 'auto']} allowDataOverflow />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: 11, color: '#9ca3af' }} />
{data.length > 50 && (
<Brush
dataKey={xKey}
height={22}
stroke="#4f8aff"
fill="#0f0f1a"
tickFormatter={v => typeof v === 'number' ? v.toFixed(3) : v}
/>
<Brush dataKey={xKey} height={22} stroke="#4f8aff" fill="#0f0f1a"
tickFormatter={v => typeof v === 'number' ? v.toFixed(3) : v} />
)}
{selectedArr.map((varName) => (
<Line
key={varName}
type="monotone"
dataKey={varName}
stroke={COLORS[variables.indexOf(varName) % COLORS.length]}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
isAnimationActive={false}
/>
{selectedArr.map(vn => (
<Line key={vn} type="monotone" dataKey={vn} name={toChinese(vn)}
stroke={COLORS[variables.indexOf(vn) % COLORS.length]}
strokeWidth={2} dot={false} activeDot={{ r: 4, strokeWidth: 0 }} isAnimationActive={false} />
))}
{isDragging && refAreaLeft != null && refAreaRight != null && (
<ReferenceArea x1={refAreaLeft} x2={refAreaRight}
strokeOpacity={0.3} fill="rgba(79,138,255,0.2)" stroke="#4f8aff" />
)}
</LineChart>
</ResponsiveContainer>
)}
......@@ -242,6 +503,7 @@ export default function SimResultsModal({ csvData, modelName, onClose }) {
</div>
)}
</div>
</div>
);
}
......@@ -141,6 +141,76 @@
font-family: 'Consolas', 'Monaco', monospace;
}
/* 工具栏 */
.toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 24px;
border-bottom: 1px solid rgba(79, 138, 255, 0.08);
flex-shrink: 0;
background: #12121f;
}
.toolBtn {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border: 1px solid rgba(79, 138, 255, 0.15);
border-radius: 6px;
background: rgba(79, 138, 255, 0.05);
color: #ccc;
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.toolBtn:hover:not(:disabled) {
background: rgba(79, 138, 255, 0.15);
color: #4f8aff;
border-color: rgba(79, 138, 255, 0.4);
}
.toolBtn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.toolIcon {
font-size: 12px;
}
.toolLabel {
font-size: 11px;
}
.toolSep {
width: 1px;
height: 20px;
background: rgba(79, 138, 255, 0.15);
margin: 0 6px;
}
.zoomBadge {
padding: 3px 10px;
background: rgba(79, 138, 255, 0.1);
border: 1px solid rgba(79, 138, 255, 0.2);
border-radius: 20px;
color: #4f8aff;
font-size: 10px;
font-family: 'Consolas', 'Monaco', monospace;
white-space: nowrap;
}
.toolTip {
margin-left: auto;
font-size: 10px;
color: #555;
white-space: nowrap;
}
/* 内容区 */
.content {
flex: 1;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment