Commit 641c56ec authored by Cloud's avatar Cloud

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

- 添加中键拖拽平移(按住滚轮拖动,带⇔视觉指示器)
- 添加滚轮缩放、左键框选放大
- 添加空格键智能裁剪(自动聚焦到数据变化区域)
- 添加工具栏:放大/缩小/撤销/重置/导出PNG
- 变量名自动翻译为中文标签(如 capacitor_1.p.v → 电容1 正极电压(V))
- 修复 React Hooks 顺序违规导致的白屏问题
- 使用 rAF 节流优化平移性能
parent 3a68f372
/** /**
* SimResultsModal - 仿真结果图表查看器(弹窗模式) * 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 { import {
LineChart, Line, XAxis, YAxis, CartesianGrid, LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, Legend, ResponsiveContainer, Brush, Tooltip, Legend, ResponsiveContainer, Brush,
ReferenceArea,
} from 'recharts'; } from 'recharts';
import styles from './SimResultsModal.module.css'; import styles from './SimResultsModal.module.css';
/** 预设调色板 — 参考 lcr_oscillator 的 accent 色系 */
const COLORS = [ const COLORS = [
'#4f8aff', '#22c55e', '#f59e0b', '#ef4444', '#00d4ff', '#4f8aff', '#22c55e', '#f59e0b', '#ef4444', '#00d4ff',
'#a855f7', '#ec4899', '#14b8a6', '#f97316', '#64748b', '#a855f7', '#ec4899', '#14b8a6', '#f97316', '#64748b',
'#84cc16', '#e879f9', '#2dd4bf', '#fb923c', '#6366f1', '#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) { function parseCSV(csv) {
if (!csv || typeof csv !== 'string') return { headers: [], data: [] }; if (!csv || typeof csv !== 'string') return { headers: [], data: [] };
const lines = csv.trim().split('\n').filter(l => l.trim()); const lines = csv.trim().split('\n').filter(l => l.trim());
if (lines.length < 2) return { headers: [], data: [] }; if (lines.length < 2) return { headers: [], data: [] };
// 首行为 header,可能带引号
const headers = lines[0].split(',').map(h => h.replace(/^"|"$/g, '').trim()); const headers = lines[0].split(',').map(h => h.replace(/^"|"$/g, '').trim());
const data = []; const data = [];
for (let i = 1; i < lines.length; i++) { for (let i = 1; i < lines.length; i++) {
const vals = lines[i].split(','); const vals = lines[i].split(',');
if (vals.length !== headers.length) continue; if (vals.length !== headers.length) continue;
const row = {}; const row = {};
let valid = true; let ok = true;
for (let j = 0; j < headers.length; j++) { for (let j = 0; j < headers.length; j++) {
const num = parseFloat(vals[j]); const n = parseFloat(vals[j]);
if (isNaN(num)) { valid = false; break; } if (isNaN(n)) { ok = false; break; }
row[headers[j]] = num; row[headers[j]] = n;
} }
if (valid) data.push(row); if (ok) data.push(row);
} }
return { headers, data }; return { headers, data };
} }
/** 自定义 Tooltip — 暗色主题 */ // ===== Tooltip =====
function CustomTooltip({ active, payload, label }) { function CustomTooltip({ active, payload, label }) {
if (!active || !payload || payload.length === 0) return null; if (!active || !payload?.length) return null;
return ( return (
<div style={{ <div style={{
background: '#1a1a2e', background: '#1a1a2e', border: '1px solid rgba(79,138,255,0.2)',
border: '1px solid rgba(79,138,255,0.2)', borderRadius: 10, padding: '10px 14px', boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
borderRadius: 10,
padding: '10px 14px',
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
}}> }}>
<div style={{ fontSize: 11, color: '#9ca3af', marginBottom: 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> </div>
{payload.map((entry, i) => ( {payload.map((e, i) => (
<div key={i} style={{ fontSize: 12, color: entry.color, marginBottom: 2 }}> <div key={i} style={{ fontSize: 12, color: e.color, marginBottom: 2 }}>
{entry.name}: <strong>{typeof entry.value === 'number' ? entry.value.toFixed(6) : entry.value}</strong> {e.name}: <strong>{typeof e.value === 'number' ? e.value.toFixed(6) : e.value}</strong>
</div> </div>
))} ))}
</div> </div>
); );
} }
/** function ToolBtn({ icon, label, onClick, disabled }) {
* @param {{csvData: string, modelName: string, onClose: () => void}} props 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 }) { export default function SimResultsModal({ csvData, modelName, onClose }) {
// --- state ---
const [selectedVars, setSelectedVars] = useState(null); 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 { 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) useEffect(() => {
const xKey = useMemo(() => { if (variables.length > 0 && selectedVars === null) {
return headers.find(h => h.toLowerCase() === 'time') || headers[0] || 'time'; setSelectedVars(new Set(variables.slice(0, Math.min(5, variables.length))));
}, [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; // 重渲染一次
} }
}, [variables]); // eslint-disable-line react-hooks/exhaustive-deps
const selected = selectedVars || new Set(); const selected = selectedVars || new Set();
const selectedArr = [...selected]; const selectedArr = useMemo(() => [...selected], [selected]);
const toggleVar = (varName) => { const fullXRange = useMemo(() => {
setSelectedVars(prev => { if (data.length === 0) return [0, 1];
const next = new Set(prev); return [data[0][xKey], data[data.length - 1][xKey]];
if (next.has(varName)) next.delete(varName); }, [data, xKey]);
else next.add(varName);
return next; 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 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 onUp = (e) => {
const selectNone = () => setSelectedVars(new Set()); 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 onCtx = (e) => { if (el.contains(e.target)) e.preventDefault(); };
// 数据质量检测 // 挂在 el 上用 capture 拦截中键,不用 stopImmediatePropagation
const hasValidData = data.length > 0 && variables.length > 0; 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 ( return (
<div className={styles.overlay} onClick={onClose}> <div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={e => e.stopPropagation()}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
{/* 头部 */}
<div className={styles.header}> <div className={styles.header}>
<div className={styles.headerLeft}> <div className={styles.headerLeft}>
<span className={styles.headerIcon}>📊</span> <span className={styles.headerIcon}>📊</span>
...@@ -127,42 +407,38 @@ export default function SimResultsModal({ csvData, modelName, onClose }) { ...@@ -127,42 +407,38 @@ export default function SimResultsModal({ csvData, modelName, onClose }) {
<button className={styles.closeBtn} onClick={onClose}></button> <button className={styles.closeBtn} onClick={onClose}></button>
</div> </div>
{!hasValidData ? ( {!hasData ? (
<div className={styles.errorState}> <div className={styles.errorState}>
<div className={styles.errorIcon}>⚠️</div> <div className={styles.errorIcon}>⚠️</div>
<div className={styles.errorText}>无法解析仿真数据</div> <div className={styles.errorText}>无法解析仿真数据</div>
<div className={styles.errorHint}> <div className={styles.errorHint}>CSV 数据格式无效或为空。请检查后端输出是否为标准 CSV 格式。</div>
CSV 数据格式无效或为空。请检查后端输出是否为标准 CSV 格式。
</div>
</div> </div>
) : ( ) : (
<div className={styles.body}> <div className={styles.body}>
{/* 统计卡片 */}
<div className={styles.statsGrid}> <div className={styles.statsGrid}>
<div className={styles.statCard}> <div className={styles.statCard}><div className={styles.statLabel}>数据点</div><div className={styles.statValue}>{data.length}</div></div>
<div className={styles.statLabel}>数据点</div> <div className={styles.statCard}><div className={styles.statLabel}>变量数</div><div className={styles.statValue}>{variables.length}</div></div>
<div className={styles.statValue}>{data.length}</div> <div className={styles.statCard}><div className={styles.statLabel}>已选</div><div className={styles.statValue}>{selectedArr.length}</div></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 && ( {data.length > 0 && (
<div className={styles.statCard}> <div className={styles.statCard}>
<div className={styles.statLabel}>时间范围</div> <div className={styles.statLabel}>时间范围</div>
<div className={styles.statValue}> <div className={styles.statValue}>{data[0][xKey]?.toFixed(3)} ~ {data[data.length - 1][xKey]?.toFixed(3)}</div>
{data[0][xKey]?.toFixed(3)} ~ {data[data.length - 1][xKey]?.toFixed(3)}
</div>
</div> </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.content}>
{/* 左侧变量面板 */}
<div className={styles.varPanel}> <div className={styles.varPanel}>
<div className={styles.varHeader}> <div className={styles.varHeader}>
<span className={styles.varTitle}>变量</span> <span className={styles.varTitle}>变量</span>
...@@ -177,63 +453,48 @@ export default function SimResultsModal({ csvData, modelName, onClose }) { ...@@ -177,63 +453,48 @@ export default function SimResultsModal({ csvData, modelName, onClose }) {
const checked = selected.has(v); const checked = selected.has(v);
return ( return (
<div key={v} className={styles.varItem} onClick={() => toggleVar(v)}> <div key={v} className={styles.varItem} onClick={() => toggleVar(v)}>
<div <div className={`${styles.varCheck} ${checked ? styles.checked : ''}`}
className={`${styles.varCheck} ${checked ? styles.checked : ''}`} style={checked ? { background: color, borderColor: color } : {}} />
style={checked ? { background: color, borderColor: color } : {}}
/>
<span className={styles.varDot} style={{ background: 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>
</div> </div>
{/* 图表区域 */} <div className={styles.chartArea} ref={chartWrapRef} onWheel={handleWheel}>
<div className={styles.chartArea}> {!selectedArr.length ? (
{selectedArr.length === 0 ? (
<div className={styles.emptyChart}> <div className={styles.emptyChart}>
<div style={{ fontSize: 40, opacity: 0.3 }}>📈</div> <div style={{ fontSize: 40, opacity: 0.3 }}>📈</div>
<div>请在左侧选择要显示的变量</div> <div>请在左侧选择要显示的变量</div>
</div> </div>
) : ( ) : (
<ResponsiveContainer width="100%" height="100%"> <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)" /> <CartesianGrid strokeDasharray="3 3" stroke="rgba(79,138,255,0.1)" />
<XAxis <XAxis dataKey={xKey} stroke="#555" tick={{ fill: '#9ca3af', fontSize: 10 }}
dataKey={xKey}
stroke="#555"
tick={{ fill: '#9ca3af', fontSize: 10 }}
tickFormatter={v => typeof v === 'number' ? v.toFixed(3) : v} tickFormatter={v => typeof v === 'number' ? v.toFixed(3) : v}
/> type="number" domain={xDomain || ['dataMin', 'dataMax']} allowDataOverflow />
<YAxis <YAxis stroke="#555" tick={{ fill: '#9ca3af', fontSize: 10 }}
stroke="#555"
tick={{ fill: '#9ca3af', fontSize: 10 }}
tickFormatter={v => typeof v === 'number' ? v.toFixed(2) : v} tickFormatter={v => typeof v === 'number' ? v.toFixed(2) : v}
/> domain={effectiveYDomain || ['auto', 'auto']} allowDataOverflow />
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: 11, color: '#9ca3af' }} /> <Legend wrapperStyle={{ fontSize: 11, color: '#9ca3af' }} />
{data.length > 50 && ( {data.length > 50 && (
<Brush <Brush dataKey={xKey} height={22} stroke="#4f8aff" fill="#0f0f1a"
dataKey={xKey} tickFormatter={v => typeof v === 'number' ? v.toFixed(3) : v} />
height={22}
stroke="#4f8aff"
fill="#0f0f1a"
tickFormatter={v => typeof v === 'number' ? v.toFixed(3) : v}
/>
)} )}
{selectedArr.map((varName) => ( {selectedArr.map(vn => (
<Line <Line key={vn} type="monotone" dataKey={vn} name={toChinese(vn)}
key={varName} stroke={COLORS[variables.indexOf(vn) % COLORS.length]}
type="monotone" strokeWidth={2} dot={false} activeDot={{ r: 4, strokeWidth: 0 }} isAnimationActive={false} />
dataKey={varName}
stroke={COLORS[variables.indexOf(varName) % 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> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
)} )}
...@@ -242,6 +503,7 @@ export default function SimResultsModal({ csvData, modelName, onClose }) { ...@@ -242,6 +503,7 @@ export default function SimResultsModal({ csvData, modelName, onClose }) {
</div> </div>
)} )}
</div> </div>
</div> </div>
); );
} }
...@@ -141,6 +141,76 @@ ...@@ -141,6 +141,76 @@
font-family: 'Consolas', 'Monaco', monospace; 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 { .content {
flex: 1; 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