前端进度条性能优化、仿真结束后后处理优化;后端C代码生成流程优化:先识别来源,再按照已知未知量需求排序,最后局部求解

This commit is contained in:
ljz committed 2026-09-11 11:27:54 +08:00
1 parent 0dcb465d84
commit 91bd9fb252
77 files changed
+11223 -551

No files matched your search

@@ -0,0 +1,45 @@
# Amesim 风格的连接线子模型演示
这是一个独立的交互 demo,用于评估“绘图 → 子模型 → 参数 → 检查”的操作方式。
## 打开与操作
在项目已有 Vite 开发服务运行时,打开:
`http://127.0.0.1:5173/demos/amesim-submodels/index.html`
1. 示例预置四条尚未选型的连接。进入“子模型”,点击连线或画布下方的连线按钮。
2. 弹窗优先展示兼容型号及物理作用;“端口需求与其他型号”默认折叠。
3. “应用并下一条”依次处理未选型连线,完成后进入参数阶段。管长、内径需要用户填写。
4. 进入“检查”,查看示例端口匹配和必填参数;问题项可以直接定位处理。
5. 绘图模式下可删除一条连线,再依次点击两个空闲端口重连。Esc 取消尚未完成的连线。
示例中的 P4NODE2 始终以端口 2 为参考,不提供节点子模型切换。
## 范围
- 仅包含固定的局部端口示例和 DIRECT、PNL00R、PNL0001/2/3 的接口匹配规则。
- 匹配检查基于温度/压力的供需关系,质量/能量流率采用互补方向;不是正式全网依赖分析。
- 优先项按接口兼容和模型复杂度排序,不代表对具体工程的物理模型推荐。各型号显示是否包含压降、储气及状态数量。
- 管路方向由端口关系决定,与画线顺序和实际气流方向无关。
- 管路配置只是交互数据,不生成正式 XML,不调用编译器、不执行仿真。
- 参数使用界面显示单位;尚未接入 SI 转换、介质、换热、完整初始条件检查等生产能力。
- 不复制 Siemens 数值代码。示例接口规则根据本机已有 Amesim 2404 管路源文件的公开接口说明整理。
- 不修改主应用、正式组件、pnode 方程、求解器或已有工程数据。没有引入 npm 依赖。
## 保存与恢复
浏览器只使用独立的 localStorage 键:
`system-simulation:amesim-submodel-demo:v1`
- 点击页面右上角“恢复初始示例”,可以清空本演示的选型与参数,重新体验。
- 返回原系统:点击“返回主应用”或打开 `http://127.0.0.1:5173/`。
- 撤回本次 demo:删除 `frontend/public/demos/amesim-submodels/` 这个目录即可。无需回退任何现有源码。
- 若只清理浏览器数据,可在该页面的开发者控制台执行 `localStorage.removeItem('system-simulation:amesim-submodel-demo:v1')`,然后刷新。
注意:这个目录位于 Vite 的 public 下,正常打包会把它作为静态文件带入构建产物。正式发布前,如尚未决定保留,应先移除这个 demo 目录。
## 本次验证
2026-09-10:JavaScript 语法及三个静态入口 HTTP 检查通过。Edge 浏览器完成兼容型号筛选、连续选型、反向端口匹配、参数校验、刷新恢复、更换型号保留共同参数、删除重连、独立重置和窄屏检查。未发起 `/api/` 请求。诊断截图保存在 Git 忽略的 `test/amesim-submodel-demo/`。
@@ -0,0 +1,256 @@
(() => {
'use strict';
const STORAGE_KEY = 'system-simulation:amesim-submodel-demo:v1';
const $ = id => document.getElementById(id);
const escape = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
// Local, deliberately bounded demonstration of variable-level interface matching.
// These declarations are not the production component catalogue or a numerical solver.
const ports = {
'A.1': { label: '储气罐 A · 端口 1', component: 'A', state: 'out' },
'N1.1': { label: '节点 N1 · 端口 1', component: 'N1', state: 'out', number: '1' },
'N1.2': { label: '节点 N1 · 端口 2(参考)', component: 'N1', state: 'in', number: '2 参考' },
'N1.3': { label: '节点 N1 · 端口 3', component: 'N1', state: 'out', number: '3' },
'N1.4': { label: '节点 N1 · 端口 4', component: 'N1', state: 'out', number: '4' },
'B.2': { label: '阀门 B · 端口 2', component: 'B', state: 'in' },
'C.1': { label: '封堵 C · 端口 1', component: 'C', state: 'in' },
'D.1': { label: '封堵 D · 端口 1', component: 'D', state: 'in' },
};
const fields = {
length: { label: '管长(m)', min: 0, exclusive: true, step: 'any', value: '' },
diameter: { label: '内径(mm)', min: 0, exclusive: true, step: 'any', value: '' },
roughness: { label: '相对粗糙度', min: 0, max: 0.1, step: 'any', value: '0.00001' },
pressure: { label: '初始压力(bar,绝压)', min: 0, exclusive: true, step: 'any', value: '1' },
temperature: { label: '初始温度(K)', min: 0, exclusive: true, step: 'any', value: '293.15' },
pressure2: { label: '管路端口 2 初始压力(bar,绝压)', min: 0, exclusive: true, step: 'any', value: '1' },
temperature2: { label: '管路端口 2 初始温度(K)', min: 0, exclusive: true, step: 'any', value: '293.15' },
};
const models = [
{ id: 'DIRECT', label: '理想连接', ends: null, effect: '不计管路压降,不增加储气容积。', fields: [] },
{ id: 'PNL00R', label: '阻性管路', ends: ['in', 'in'], effect: '考虑摩擦压降;不增加储气状态。', fields: ['length', 'diameter', 'roughness'] },
{ id: 'PNL0001', label: '单端储气管路', ends: ['in', 'out'], effect: '考虑摩擦压降;一处储气容积,2 个热力状态。', fields: ['length', 'diameter', 'roughness', 'pressure', 'temperature'] },
{ id: 'PNL0002', label: '中间储气管路', ends: ['in', 'in'], effect: '考虑摩擦压降;中间储气容积,2 个热力状态。', fields: ['length', 'diameter', 'roughness', 'pressure', 'temperature'] },
{ id: 'PNL0003', label: '双端储气管路', ends: ['out', 'out'], effect: '考虑摩擦压降;两端储气容积,4 个热力状态。', fields: ['length', 'diameter', 'roughness', 'pressure', 'temperature', 'pressure2', 'temperature2'] },
];
const modelById = id => models.find(model => model.id === id);
const initial = () => ({ version: 1, mode: 'sketch', selected: 'L1', edges: [
{ id: 'L1', a: 'A.1', b: 'N1.1', model: null, params: {} },
{ id: 'L2', a: 'N1.2', b: 'B.2', model: null, params: {} },
{ id: 'L3', a: 'C.1', b: 'N1.3', model: null, params: {} },
{ id: 'L4', a: 'N1.4', b: 'D.1', model: null, params: {} },
] });
let state = initial(), pendingPort = null, dialogEdge = null, positions = {};
let storageAvailable = true;
function matches(edge, model) {
const a = ports[edge.a].state, b = ports[edge.b].state;
if (!model.ends) return a !== b;
return (a !== model.ends[0] && b !== model.ends[1]) || (a !== model.ends[1] && b !== model.ends[0]);
}
function candidates(edge) { return models.filter(model => matches(edge, model)); }
function load() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
if (!saved || saved.version !== 1 || !Array.isArray(saved.edges) || saved.edges.length > 4) return;
const occupied = new Set(), ids = new Set();
const valid = saved.edges.every(edge => {
if (!edge || !/^L[1-9]\d*$/.test(edge.id) || ids.has(edge.id) || !ports[edge.a] || !ports[edge.b] ||
ports[edge.a].component === ports[edge.b].component || occupied.has(edge.a) || occupied.has(edge.b)) return false;
if (edge.model !== null && (!modelById(edge.model) || !matches(edge, modelById(edge.model)))) return false;
ids.add(edge.id); occupied.add(edge.a); occupied.add(edge.b);
return true;
});
if (!valid) return;
state = {
version: 1,
mode: ['sketch', 'submodel', 'parameters', 'check'].includes(saved.mode) ? saved.mode : 'sketch',
selected: ids.has(saved.selected) ? saved.selected : saved.edges[0]?.id || null,
edges: saved.edges.map(edge => ({ id: edge.id, a: edge.a, b: edge.b, model: edge.model,
params: Object.fromEntries(Object.keys(fields).filter(key => ['string', 'number'].includes(typeof edge.params?.[key])).map(key => [key, String(edge.params[key]).slice(0, 64)])) })),
};
} catch { storageAvailable = false; }
}
function save() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); storageAvailable = true; }
catch { storageAvailable = false; }
$('save-state').textContent = storageAvailable ? '仅保存此演示 · 与正式工程分离' : '当前浏览器未保存 · 刷新将丢失演示修改';
}
function selected() { return state.edges.find(edge => edge.id === state.selected); }
function message(text) { $('notice').textContent = text; }
function validField(key, value) {
const field = fields[key], number = Number(value);
return String(value ?? '').trim() !== '' && Number.isFinite(number) && (field.exclusive ? number > field.min : number >= field.min) && (field.max === undefined || number <= field.max);
}
function missingFields(edge) {
return (modelById(edge.model)?.fields || []).filter(key => !validField(key, edge.params[key]));
}
function edgeStatus(edge) {
if (!edge.model) return '未选子模型';
return missingFields(edge).length ? '待填参数' : '已配置';
}
function orientation(edge, model) {
if (!model.ends || model.ends[0] === model.ends[1]) return '两端接口对称';
const forward = ports[edge.a].state !== model.ends[0];
return `管路端口 1 → ${ports[forward ? edge.a : edge.b].label};端口 2 → ${ports[forward ? edge.b : edge.a].label}`;
}
function updateProgress() {
const chosen = state.edges.filter(edge => edge.model).length;
const configured = state.edges.filter(edge => edge.model && missingFields(edge).length === 0).length;
$('progress').textContent = `${state.edges.length} 条连接 · ${chosen} 条已选子模型 · ${configured} 条参数完整`;
}
function layout() {
const width = $('canvas').clientWidth, height = $('canvas').clientHeight;
$('wires').setAttribute('viewBox', `0 0 ${width} ${height}`);
positions = {
'A.1': [width * .16 + 29, height * .54],
'N1.1': [width * .48 - 19, height * .54], 'N1.2': [width * .48 + 19, height * .54],
'N1.3': [width * .48, height * .54 - 19], 'N1.4': [width * .48, height * .54 + 19],
'B.2': [width * .84 - 29, height * .54],
'C.1': [width * .48, height * .14 + 4], 'D.1': [width * .48, height * .87 - 4],
};
$('ports').innerHTML = Object.entries(ports).map(([id, port]) => `<button type="button" class="port ${id === pendingPort ? 'pending' : ''} ${id === 'N1.2' ? 'reference' : ''}" style="--x:${positions[id][0]}px;--y:${positions[id][1]}px" data-port="${id}" aria-label="${escape(port.label)}">${port.number ? `<span>${escape(port.number)}</span>` : ''}</button>`).join('');
renderWires();
}
function renderWires() {
if (!positions['A.1']) return;
$('wires').innerHTML = state.edges.map(edge => {
const [ax, ay] = positions[edge.a], [bx, by] = positions[edge.b];
const horizontal = Math.abs(bx - ax) >= Math.abs(by - ay);
const path = horizontal ? `M${ax},${ay} H${(ax + bx) / 2} V${by} H${bx}` : `M${ax},${ay} V${(ay + by) / 2} H${bx} V${by}`;
const x = horizontal ? (ax + bx) / 2 : (ax + bx) / 2 + 13;
const y = horizontal ? (ay + by) / 2 - 12 : (ay + by) / 2;
const label = state.mode === 'sketch' ? edge.id : `${edge.id} · ${edge.model || '未选'}`;
return `<g><path class="wire-line ${edge.model ? 'configured' : ''} ${edge.id === state.selected ? 'selected' : ''}" d="${path}"/><path data-edge="${edge.id}" class="wire-hit" d="${path}"/><text class="wire-label" x="${x}" y="${y}" text-anchor="${horizontal ? 'middle' : 'start'}">${escape(label)}</text></g>`;
}).join('');
$('connection-strip').innerHTML = state.edges.map(edge => `<button type="button" data-edge="${edge.id}" aria-pressed="${edge.id === state.selected}"><span class="dot ${edge.model ? 'ready' : ''}"></span>${edge.id}<span>${escape(state.mode === 'sketch' ? '连接线' : edge.model || '未选')}</span></button>`).join('') || '<span class="secondary">依次点击两个空闲端口创建连接。</span>';
}
function setMode(mode) { state.mode = mode; pendingPort = null; render(); save(); }
function selectEdge(id, open = false) {
state.selected = id; pendingPort = null; render(); save();
if (open && state.mode === 'submodel') openChooser(id);
}
function renderInspector() {
const edge = selected();
$('inspector-title').textContent = state.mode === 'check' ? '配置检查' : state.mode === 'parameters' ? '连线参数' : '连线';
$('selected-id').textContent = state.mode === 'check' ? '局部演示' : edge?.id || '';
if (state.mode === 'check') { renderChecks(); return; }
if (!edge) { $('inspector-body').innerHTML = '<p class="secondary">选择连接线,或在绘图模式连接两个空闲端口。</p>'; return; }
const model = modelById(edge.model);
let html = `<div class="endpoint"><div>${escape(ports[edge.a].label)}</div><div>↔ ${escape(ports[edge.b].label)}</div></div>`;
if (state.mode === 'sketch') {
html += '<p class="secondary">先完成连接。进入子模型阶段后,为连接线选择计算模型。</p><div class="inspector-actions"><button type="button" class="primary" data-action="submodel">进入子模型</button><button type="button" data-action="delete">删除此连线</button></div>';
} else {
html += `<div class="section-label">当前子模型</div><div class="model-name">${escape(model?.id || '尚未选择')}</div><div class="secondary">${escape(model?.effect || '根据两端接口筛选,保留用户选择。')}</div>`;
if (model) html += `<p class="secondary">${escape(orientation(edge, model))}</p>`;
html += `<div class="inspector-actions"><button type="button" class="${state.mode === 'submodel' ? 'primary' : ''}" data-action="choose">${model ? '更换子模型…' : '选择子模型…'}</button>${state.mode === 'submodel' && model ? '<button type="button" data-action="parameters">填写参数</button>' : ''}</div>`;
if (state.mode === 'parameters' && model) {
html += model.fields.length ? `<form class="parameter-form" novalidate>${model.fields.map(key => {
const field = fields[key];
const label = model.id === 'PNL0003' && ['pressure', 'temperature'].includes(key) ? '管路端口 1 ' + field.label : field.label;
return `<label for="param-${key}">${escape(label)}<input id="param-${key}" data-parameter="${key}" type="number" min="${field.min}" ${field.max !== undefined ? `max="${field.max}"` : ''} step="${field.step}" value="${escape(edge.params[key] ?? '')}" placeholder="填写实际值" aria-invalid="${!validField(key, edge.params[key])}"></label>`;
}).join('')}</form><div class="parameter-state" id="parameter-state"></div>` : '<p class="secondary">理想连接无需管路参数。</p>';
}
}
$('inspector-body').innerHTML = html;
updateParameterState();
}
function updateParameterState() {
const target = $('parameter-state'), edge = selected();
if (!target || !edge) return;
const missing = missingFields(edge);
target.classList.toggle('valid', missing.length === 0);
target.textContent = missing.length ? `待填写或修正 ${missing.length} 项` : '参数完整 · 已保存到此演示';
}
function renderChecks() {
const occupied = new Set(state.edges.flatMap(edge => [edge.a, edge.b]));
const free = Object.keys(ports).filter(id => !occupied.has(id));
const complete = !free.length && state.edges.every(edge => edge.model && matches(edge, modelById(edge.model)) && !missingFields(edge).length);
$('inspector-body').innerHTML = `<div class="check-summary ${complete ? 'complete' : ''}">${complete ? '局部连接配置检查通过' : '还有配置需要完成'}</div><p class="secondary">此检查覆盖示例端口匹配和必填参数;未调用系统编译器或求解器。</p><div class="check-list">${state.edges.map(edge => `<div class="check-item ${edgeStatus(edge) === '已配置' ? 'complete' : ''}"><strong>${edge.id} · ${escape(edge.model || '未选')}</strong><div class="secondary">${escape(edgeStatus(edge))}</div>${edgeStatus(edge) !== '已配置' ? `<button type="button" data-fix="${edge.id}">${edge.model ? '填写参数' : '选择子模型'}</button>` : ''}</div>`).join('')}${free.length ? `<div class="check-item"><strong>${free.length} 个示例端口未连接</strong><div class="secondary">${free.map(id => escape(ports[id].label)).join('、')}</div><button type="button" data-action="sketch">返回绘图</button></div>` : ''}</div>`;
}
function render() {
document.querySelectorAll('[data-mode]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.mode === state.mode)));
const hints = {
sketch: '示例已连线,可进入“子模型”;也可删除连线后重新连接端口。',
submodel: '点击连接线选择兼容子模型。pnode 保持不变,管路方向自动匹配。',
parameters: '点击管路填写实际尺寸。理想连接无需参数。',
check: '检查本示例的端口匹配和必填参数。',
};
$('mode-hint').textContent = hints[state.mode];
const next = { sketch: '选择子模型 →', submodel: '填写参数 →', parameters: '检查配置 →', check: '返回绘图' };
$('next-mode').textContent = next[state.mode];
layout(); renderInspector(); updateProgress();
}
function openChooser(id) {
const edge = state.edges.find(item => item.id === id);
if (!edge) return;
dialogEdge = id;
const available = candidates(edge), selectedModel = edge.model || available[0]?.id;
$('dialog-title').textContent = `选择 ${id} 的子模型`;
$('dialog-endpoints').textContent = `${ports[edge.a].label} ↔ ${ports[edge.b].label}`;
$('model-options').innerHTML = available.map((model, index) => `<label class="model-option"><input type="radio" name="model" value="${model.id}" ${model.id === selectedModel ? 'checked' : ''}><span><strong>${model.id}</strong>${escape(model.label)} ${index === 0 ? '<span class="recommendation">接口匹配优先项</span>' : ''}<small>${escape(model.effect)}</small></span></label>`).join('');
const describe = id => `${ports[id].label}:${ports[id].state === 'out' ? '提供温度、压力,接收质量与能量流率' : '接收温度、压力,提供质量与能量流率'}`;
$('port-requirements').innerHTML = `<div>${escape(describe(edge.a))}</div><div>${escape(describe(edge.b))}</div>`;
$('excluded-models').innerHTML = models.filter(model => !matches(edge, model)).map(model => `<div class="excluded-model"><span>${model.id}</span><span>端口变量供需不匹配</span></div>`).join('');
$('apply-model').disabled = !available.length;
$('apply-next').disabled = !available.length;
document.querySelector('.compatibility-details').open = false;
if (!$('model-dialog').open) $('model-dialog').showModal();
}
function applyModel(next) {
const edge = state.edges.find(item => item.id === dialogEdge);
const id = document.querySelector('input[name="model"]:checked')?.value;
const model = modelById(id);
if (!edge || !model || !matches(edge, model)) return;
edge.model = id;
// Keep shared inputs when changing a model. Inactive inputs remain private to this draft.
model.fields.forEach(key => { if (edge.params[key] === undefined) edge.params[key] = fields[key].value; });
state.selected = edge.id;
$('model-dialog').close(); render(); save();
message(`${edge.id} 已选择 ${id}。${model.effect}`);
if (next) {
const unassigned = state.edges.find(item => !item.model);
if (unassigned) { selectEdge(unassigned.id); openChooser(unassigned.id); }
else { setMode('parameters'); state.selected = state.edges.find(item => missingFields(item).length)?.id || edge.id; render(); save(); message('所有连接线已选型。填写实际管长、内径后检查配置。'); }
}
}
function portClick(id) {
if (state.mode !== 'sketch') { message('如需修改连接,请切换到“绘图”。'); return; }
if (pendingPort === id) { pendingPort = null; layout(); message('已取消连线。'); return; }
if (state.edges.some(edge => edge.a === id || edge.b === id)) { message('该端口已有连接。选中原连线可删除后重连。'); return; }
if (!pendingPort) { pendingPort = id; layout(); message(`起点:${ports[id].label}。请选择另一个部件的空闲端口。`); return; }
if (ports[pendingPort].component === ports[id].component) { message('请选择另一个部件的端口。'); return; }
let index = 1;
while (state.edges.some(edge => edge.id === `L${index}`)) index++;
const edge = { id: `L${index}`, a: pendingPort, b: id, model: null, params: {} };
state.edges.push(edge); state.selected = edge.id; pendingPort = null; render(); save();
message(`${edge.id} 已连接。子模型稍后选择。`);
}
document.querySelectorAll('[data-mode]').forEach(button => button.addEventListener('click', () => setMode(button.dataset.mode)));
$('next-mode').addEventListener('click', () => setMode({ sketch: 'submodel', submodel: 'parameters', parameters: 'check', check: 'sketch' }[state.mode]));
$('wires').addEventListener('click', event => { const target = event.target.closest('[data-edge]'); if (target) selectEdge(target.dataset.edge, true); });
$('connection-strip').addEventListener('click', event => { const target = event.target.closest('[data-edge]'); if (target) selectEdge(target.dataset.edge, true); });
$('ports').addEventListener('click', event => { const target = event.target.closest('[data-port]'); if (target) portClick(target.dataset.port); });
$('inspector-body').addEventListener('click', event => {
const fix = event.target.closest('[data-fix]');
if (fix) { state.selected = fix.dataset.fix; const edge = selected(); setMode(edge.model ? 'parameters' : 'submodel'); if (!edge.model) openChooser(edge.id); return; }
const action = event.target.closest('[data-action]')?.dataset.action;
if (action === 'choose') openChooser(state.selected);
else if (action === 'delete') { state.edges = state.edges.filter(edge => edge.id !== state.selected); state.selected = state.edges[0]?.id || null; pendingPort = null; render(); save(); message('连线已删除。依次点击两个空闲端口即可重连。'); }
else if (['sketch', 'submodel', 'parameters'].includes(action)) setMode(action);
});
$('inspector-body').addEventListener('input', event => {
const key = event.target.dataset.parameter, edge = selected();
if (!fields[key] || !edge) return;
edge.params[key] = event.target.value;
event.target.setAttribute('aria-invalid', String(!validField(key, event.target.value)));
updateParameterState(); updateProgress(); save();
});
$('inspector-body').addEventListener('submit', event => event.preventDefault());
$('apply-model').addEventListener('click', () => applyModel(false));
$('apply-next').addEventListener('click', () => applyModel(true));
$('reset').addEventListener('click', () => {
$('model-dialog').close(); state = initial(); pendingPort = null; dialogEdge = null;
render(); save(); message('已恢复最初的四条连接;此演示的选型和参数已清空。');
});
document.addEventListener('keydown', event => { if (event.key === 'Escape' && pendingPort) { pendingPort = null; layout(); message('已取消连线。'); } });
load(); render(); save();
new ResizeObserver(layout).observe($('canvas'));
})();
@@ -0,0 +1,57 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>连接线子模型 · 操作演示</title>
<link rel="stylesheet" href="./styles.css">
<script src="./app.js" defer></script>
</head>
<body>
<header class="topbar">
<div><strong>System Simulation</strong><span class="demo-tag">子模型操作演示</span></div>
<div class="top-actions"><a href="/">返回主应用</a><button id="reset" type="button">恢复初始示例</button></div>
</header>
<nav class="workflow" aria-label="建模步骤">
<button type="button" data-mode="sketch" aria-pressed="true"><span>1</span>绘图</button>
<button type="button" data-mode="submodel" aria-pressed="false"><span>2</span>子模型</button>
<button type="button" data-mode="parameters" aria-pressed="false"><span>3</span>参数</button>
<button type="button" data-mode="check" aria-pressed="false"><span>4</span>检查</button>
<span class="workflow-note">独立配置演示 · 不执行仿真</span>
</nav>
<main class="workspace">
<section class="canvas-pane" aria-label="局部气动网络">
<div class="canvas-toolbar"><span id="mode-hint">示例已连线,可进入“子模型”;也可删除连线后重新连接端口。</span><button type="button" id="next-mode">选择子模型 →</button></div>
<div id="canvas" class="canvas">
<svg id="wires" viewBox="0 0 900 500" aria-label="连接线" role="group"></svg>
<div class="component tank" style="--x:16%;--y:54%"><div class="tank-symbol"></div><strong>储气罐 A</strong><small>提供温度、压力</small></div>
<div class="component junction" style="--x:48%;--y:54%"><div class="junction-symbol"></div><strong>节点 N1</strong><small>P4NODE2 · 固定</small></div>
<div class="component valve" style="--x:84%;--y:54%"><div class="valve-symbol">↗</div><strong>阀门 B</strong><small>需要温度、压力</small></div>
<div class="component plug" style="--x:48%;--y:14%"><div class="plug-symbol"></div><strong>封堵 C</strong></div>
<div class="component plug" style="--x:48%;--y:87%"><div class="plug-symbol"></div><strong>封堵 D</strong></div>
<div id="ports"></div>
<div class="canvas-caption">只展示本次选型涉及的端口;部件位置及 pnode 子模型固定。</div>
</div>
<div class="connection-strip" id="connection-strip" aria-label="选择连接线"></div>
<div id="notice" role="status" aria-live="polite" class="notice">先保留连接,再确定每条线的计算模型。</div>
</section>
<aside class="inspector" aria-label="连线设置">
<div class="inspector-title"><strong id="inspector-title">连线</strong><span id="selected-id"></span></div>
<div id="inspector-body" class="inspector-body"></div>
</aside>
</main>
<footer class="statusbar"><span id="progress"></span><span id="save-state">演示独立保存</span></footer>
<dialog id="model-dialog" aria-labelledby="dialog-title">
<form method="dialog" class="dialog-shell">
<div class="dialog-header"><div><h1 id="dialog-title">选择连接线子模型</h1><div id="dialog-endpoints" class="secondary"></div></div><button type="submit" class="icon-button" aria-label="关闭子模型窗口">×</button></div>
<div class="dialog-content">
<div class="selection-context"><strong>兼容的子模型</strong><span>方向自动匹配</span></div>
<div id="model-options" role="radiogroup" aria-label="兼容子模型"></div>
<div class="choice-note">优先项按接口匹配与模型复杂度排序。是否计入压降、储气,请按实际管路选择。</div>
<details class="compatibility-details"><summary>端口需求与其他型号</summary><div id="port-requirements"></div><div id="excluded-models"></div></details>
</div>
<div class="dialog-footer"><button type="submit">取消</button><button id="apply-model" type="button">应用</button><button id="apply-next" type="button" class="primary">应用并下一条</button></div>
</form>
</dialog>
</body>
</html>
@@ -0,0 +1,108 @@
:root { font: 14px/1.5 Inter, "Segoe UI", "Microsoft YaHei", sans-serif; color: #263442; background: #edf1f5; --blue: #1764a0; --border: #cfd8e3; --muted: #647487; }
* { box-sizing: border-box; }
body { margin: 0; }
button, input, select { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { cursor: pointer; color: #263442; border: 1px solid var(--border); background: #fff; border-radius: 4px; padding: 6px 12px; }
button:hover { background: #f0f5fa; }
button:disabled { cursor: default; opacity: .5; }
button:focus-visible, a:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid #247ec5; outline-offset: 3px; }
a { color: var(--blue); text-decoration: none; }
strong { font-weight: 600; }
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; background: #fff; border-bottom: 1px solid var(--border); }
.topbar strong { font-size: 16px; }
.demo-tag { display: inline-block; margin-left: 14px; color: var(--muted); font-size: 12px; }
.top-actions { display: flex; align-items: center; gap: 18px; }
.workflow { display: flex; align-items: center; gap: 4px; padding: 7px 18px; background: #f8fafc; border-bottom: 1px solid var(--border); }
.workflow button { display: flex; align-items: center; gap: 8px; background: transparent; border-color: transparent; padding: 8px 18px; }
.workflow button span { display: inline-grid; place-items: center; width: 21px; height: 21px; border-radius: 50%; background: #e3e9ef; font-size: 12px; }
.workflow button[aria-pressed="true"] { background: #e7f1fa; border-color: #b4cfe4; color: #155a94; }
.workflow button[aria-pressed="true"] span { background: var(--blue); color: #fff; }
.workflow-note { margin-left: auto; color: var(--muted); font-size: 12px; }
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 310px; min-height: calc(100vh - 143px); }
.canvas-pane { min-width: 0; display: flex; flex-direction: column; }
.canvas-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 11px 16px; min-height: 55px; border-bottom: 1px solid var(--border); font-size: 12px; }
.canvas-toolbar button { white-space: nowrap; font-size: 13px; }
.canvas { position: relative; width: 100%; aspect-ratio: 1.8; min-height: 420px; max-height: 620px; background-color: #fff; background-image: radial-gradient(#d3dce5 .7px, transparent .7px); background-size: 20px 20px; }
#wires { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.wire-line { fill: none; stroke: #8b9aaa; stroke-width: 2; stroke-dasharray: 6 4; vector-effect: non-scaling-stroke; pointer-events: none; }
.wire-line.configured { stroke: #526c7c; stroke-dasharray: none; }
.wire-line.selected { stroke: #1472bd; stroke-width: 3; }
.wire-hit { fill: none; stroke: transparent; stroke-width: 20; pointer-events: stroke; cursor: pointer; }
.wire-label { fill: #596f84; font: 13px "Segoe UI", "Microsoft YaHei", sans-serif; paint-order: stroke; stroke: #fff; stroke-width: 5px; stroke-linejoin: round; pointer-events: none; }
.component { position: absolute; left: var(--x); top: var(--y); transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; pointer-events: none; }
.component strong { position: absolute; top: calc(100% + 12px); white-space: nowrap; font-size: 13px; }
.component small { position: absolute; top: calc(100% + 33px); color: var(--muted); white-space: nowrap; font-size: 11px; }
.junction strong, .junction small { left: calc(100% + 18px); }
.tank-symbol { width: 58px; height: 76px; border: 2px solid #506579; border-radius: 16px; background: #f8fafc; }
.junction-symbol { width: 38px; height: 38px; position: relative; }
.junction-symbol::before, .junction-symbol::after { content: ""; position: absolute; background: #344f68; }
.junction-symbol::before { width: 38px; height: 3px; top: 17px; }
.junction-symbol::after { width: 3px; height: 38px; left: 17px; }
.valve-symbol { width: 58px; height: 43px; display: grid; place-items: center; font: 33px/1 sans-serif; border: 2px solid #506579; background: #f8fafc; }
.plug-symbol { width: 24px; height: 8px; background: repeating-linear-gradient(130deg, #506579 0 2px, #fff 2px 5px); border-bottom: 2px solid #506579; }
.plug strong { top: -5px; left: calc(100% + 12px); }
.port { position: absolute; left: var(--x); top: var(--y); transform: translate(-50%, -50%); width: 27px; height: 27px; padding: 0; display: grid; place-items: center; border: 0; background: transparent; border-radius: 50%; }
.port::before { content: ""; width: 9px; height: 9px; background: #fff; border: 2px solid #4c6d87; border-radius: 50%; }
.port.pending { background: #e3effa; }
.port.pending::before { background: #1472bd; border-color: #1472bd; }
.port span { position: absolute; left: 23px; top: -4px; color: #516678; font-size: 10px; background: #fff; white-space: nowrap; pointer-events: none; }
.port.reference::before { border-color: #b37621; }
.port.reference span { color: #8d5d1b; }
.canvas-caption { position: absolute; bottom: 10px; left: 14px; color: #738292; font-size: 11px; }
.connection-strip { display: flex; flex-wrap: wrap; gap: 8px; padding: 13px 16px; border-top: 1px solid var(--border); background: #f8fafc; }
.connection-strip button { font-size: 12px; display: flex; gap: 7px; align-items: center; }
.connection-strip button[aria-pressed="true"] { color: var(--blue); border-color: #6299c5; background: #eaf3fb; }
.dot { width: 6px; height: 6px; background: #adbac7; border-radius: 50%; }
.dot.ready { background: #427e63; }
.notice { margin-top: auto; padding: 13px 16px; color: #526679; font-size: 12px; border-top: 1px solid #dbe2e9; }
.inspector { border-left: 1px solid var(--border); background: #fff; min-width: 0; }
.inspector-title { display: flex; justify-content: space-between; padding: 14px 16px; background: #f5f7fa; border-bottom: 1px solid var(--border); }
.inspector-title span { color: var(--muted); }
.inspector-body { padding: 16px; }
.secondary { font-size: 12px; color: var(--muted); }
.endpoint { color: #435a6e; overflow-wrap: anywhere; padding: 0 0 14px; }
.endpoint div + div { margin-top: 5px; }
.section-label { font-size: 12px; color: var(--muted); margin: 16px 0 6px; }
.model-name { font-size: 18px; font-weight: 600; margin: 0 0 5px; }
.inspector-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 16px 0; }
.primary { background: #1a68a6; border-color: #1a68a6; color: #fff; }
.primary:hover { background: #145b94; }
.inspector-body p { margin: 8px 0 14px; }
.parameter-form { display: grid; gap: 12px; margin-top: 14px; }
.parameter-form label { display: grid; gap: 5px; color: #405568; font-size: 12px; }
.parameter-form input { width: 100%; border: 1px solid #bdcbd8; border-radius: 3px; padding: 7px 9px; font-size: 14px; color: #243849; }
.parameter-form input[aria-invalid="true"] { border-color: #b46d22; }
.parameter-state { font-size: 12px; padding: 8px 0; color: #7b5a2c; }
.parameter-state.valid { color: #337055; }
.check-list { display: grid; gap: 8px; }
.check-item { padding: 10px; border: 1px solid #dce3e9; border-radius: 4px; }
.check-item button { margin-top: 7px; font-size: 12px; }
.check-item.complete { border-left: 3px solid #549375; }
.check-summary { padding: 10px 0; font-weight: 600; }
.check-summary.complete { color: #337055; }
.statusbar { display: flex; gap: 12px; justify-content: space-between; padding: 8px 16px; background: #f8fafc; border-top: 1px solid var(--border); color: #657588; font-size: 12px; }
dialog { width: min(620px, calc(100vw - 32px)); padding: 0; border: 1px solid #b6c4d1; border-radius: 7px; color: #263442; background: #fff; box-shadow: 0 16px 65px #182e4533; max-height: calc(100dvh - 32px); }
dialog::backdrop { background: #1c304347; }
.dialog-header { display: flex; align-items: start; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border); padding: 17px 20px; }
h1 { font-size: 17px; margin: 0 0 5px; font-weight: 600; }
.icon-button { border: 0; font-size: 23px; padding: 0 8px; line-height: 1.3; }
.dialog-content { padding: 16px 20px; }
.selection-context { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-bottom: 10px; font-size: 12px; }
.selection-context span { color: var(--muted); }
#model-options { display: grid; gap: 8px; }
.model-option { display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 10px; align-items: start; border: 1px solid #d3dde6; border-radius: 5px; padding: 12px; cursor: pointer; }
.model-option:has(input:checked) { border-color: #3382be; background: #f0f7fc; }
.model-option input { margin: 4px 0 0; accent-color: var(--blue); }
.model-option strong { display: inline-block; margin-right: 8px; }
.model-option small { display: block; color: #566c80; margin-top: 4px; font-size: 12px; }
.recommendation { font-size: 11px; color: #1f638f; font-weight: 400; }
.choice-note { font-size: 12px; color: #5d6b7a; margin: 12px 0; }
details { font-size: 12px; color: #5d6e80; }
summary { cursor: pointer; padding: 5px 0; }
#port-requirements { line-height: 1.8; margin: 7px 0; }
.excluded-model { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; border-top: 1px solid #e5ebf1; }
.dialog-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; padding: 12px 20px; background: #f6f8fa; border-top: 1px solid var(--border); }
@media(max-width: 1000px) { .workspace { grid-template-columns: minmax(0, 1fr) 275px; } .canvas { min-height: 390px; } .workflow button { padding: 7px 12px; } .workflow-note { display: none; } .component small { display: none; } }
@media(max-width: 760px) { .workspace { display: block; } .canvas { min-height: 340px; } .inspector { border-left: 0; border-top: 1px solid var(--border); } .topbar { align-items: start; padding: 12px; } .demo-tag { display: block; margin: 3px 0 0; } .top-actions { gap: 10px; font-size: 12px; } .workflow { padding: 7px; justify-content: space-between; } .workflow button { padding: 7px 9px; gap: 5px; } .canvas-toolbar { align-items: start; } .canvas-caption { font-size: 10px; max-width: 90%; } .component strong { font-size: 11px; } .statusbar { flex-wrap: wrap; } }
@media(pointer: coarse) { button, .port { min-height: 44px; } input[type="number"] { min-height: 44px; font-size: 16px; } }
@@ -0,0 +1,42 @@
# 管路子模型三栏布局 demo
打开:`http://127.0.0.1:5173/demos/pipe-submodels/index.html`
此版只开放 PNL00R、PNL0001、PNL0002、PNL0003 的管路子模型。左侧为管路子模型库,中间为固定气动网络,右侧为子模型属性及参数表,底部为配置检查信息。采用主应用的三栏、工作模式标签、紧凑工具栏和参数表结构,借鉴 Amesim 的子模型工作模式;不是完整复刻 Amesim 界面。
## 操作
1. 默认进入“子模型”。单击画布中的管路或左侧管路列表进行选择。
2. 左侧默认只展示兼容管路型号。点击预览,在右侧“应用到 L…”或“应用并下一条”。双击型号也可应用。
3. 切换“参数”后编辑数值;子模型阶段参数只读,避免选型时误改物理参数。
4. 切换“检查”,底部显示本例的局部匹配和必填参数检查。点击问题项直接定位。
5. 滚轮或左下角按钮缩放;拖动画布空白区平移查看;“适配视图”恢复视口。
## 位置锁定
- 部件坐标和连接端点为固定定义,选型及参数状态不包含坐标。
- 所有模式均不提供部件拖动、连线改路由、重新连接、删除、旋转或镜像。
- pnode 固定为 P4NODE2,参考端口不变。其他部件只作为管路端点的上下文。
- 允许的平移、缩放只改变观察视口,不改变模型坐标或连接。
## 演示范围
- 四条管路覆盖两侧提供状态、两侧需要状态、一侧提供/另一侧需要状态的局部匹配场景。
- 不包含其他组件子模型选型;理想连接不进入本版管路库。
- 管路接口元数据按上一版已核对的本机 Amesim 管路定义整理,数值代码未复制。
- C/R 图标沿用主应用储气/阻力表示。管路实际端口映射见“端口方向与选型说明”,与画线顺序无关。
- 子模型切换可能改变是否计入储气、储气分布及状态数量,界面明确展示其物理作用。仅按接口筛选,不能替代工程物理选型。
- 仅为固定局部网络的交互 demo。未接入全网方程分析、XML、SI 单位转换或仿真后端,不执行求解。
- 主应用及正式组件、pnode、数值内核不作修改。没有新增 npm 依赖。
## 恢复
- “重置演示”只重置本页的选型与参数。
- 本版独立存储键:`system-simulation:pipe-submodels-layout-demo:v1`。
- 上一版完整保留在 `../amesim-submodels/index.html`,两个演示互不读取对方存档。
- 撤回此版只需移除 `frontend/public/demos/pipe-submodels/`,不需要回退其他源码。
- 位于 Vite public 下的 demo 会随前端打包输出;正式发布前可移除演示目录。
## 本次验证
2026-09-10:JavaScript 语法与静态入口检查通过。Edge 浏览器验证部件拖动、连线拖动与 Delete 均不改变固定坐标/连接;缩放、空白区平移只改变视口。四种管路的兼容筛选、预览与应用分离、连续选型、子模型阶段参数只读、参数校验、更换型号保留共同参数、反向端口映射、刷新恢复及独立重置通过。桌面、窄桌面和移动宽度没有横向溢出,未发起后端 API 请求。检查截图在 `test/pipe-submodels-demo/`。
+313
View File
@@ -0,0 +1,313 @@
(() => {
'use strict';
const KEY = 'system-simulation:pipe-submodels-layout-demo:v1';
const $ = id => document.getElementById(id);
const safe = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
// The demo never stores component coordinates or connection endpoints in editable state.
const endpoints = Object.freeze({
'A.1': Object.freeze({ label: '储气罐 A · 端口 1', state: 'out' }),
'N1.1': Object.freeze({ label: '节点 N1 · 端口 1', state: 'out' }),
'N1.2': Object.freeze({ label: '节点 N1 · 端口 2(参考)', state: 'in' }),
'N1.3': Object.freeze({ label: '节点 N1 · 端口 3', state: 'out' }),
'N1.4': Object.freeze({ label: '节点 N1 · 端口 4', state: 'out' }),
'B.2': Object.freeze({ label: '阀门 B · 端口 2', state: 'in' }),
'C.1': Object.freeze({ label: '封堵 C · 端口 1', state: 'in' }),
'D.1': Object.freeze({ label: '封堵 D · 端口 1', state: 'in' }),
});
const PIPES = Object.freeze([
Object.freeze({ id: 'L1', a: 'A.1', b: 'N1.1', path: 'M175 360H460', x: 315, y: 334, anchor: 'middle' }),
Object.freeze({ id: 'L2', a: 'N1.2', b: 'B.2', path: 'M500 360H790', x: 645, y: 334, anchor: 'middle' }),
Object.freeze({ id: 'L3', a: 'C.1', b: 'N1.3', path: 'M480 134V340', x: 507, y: 240, anchor: 'start' }),
Object.freeze({ id: 'L4', a: 'N1.4', b: 'D.1', path: 'M480 380V566', x: 507, y: 485, anchor: 'start' }),
]);
const MODELS = [
{ id: 'PNL00R', name: '阻性管路', sections: ['R'], ends: ['in', 'in'], caption: '压降 · 无储气', description: '计算摩擦压降,不增加管内储气状态。', states: 0, fields: ['length', 'diameter', 'roughness'] },
{ id: 'PNL0001', name: '单端储气管路', sections: ['C', 'R'], ends: ['in', 'out'], caption: '单端储气', description: '考虑摩擦压降与一处储气容积,包含 2 个热力状态。', states: 2, fields: ['length', 'diameter', 'roughness', 'pressure', 'temperature'] },
{ id: 'PNL0002', name: '中间储气管路', sections: ['R', 'C', 'R'], ends: ['in', 'in'], caption: '中间储气', description: '考虑摩擦压降与中间储气容积,包含 2 个热力状态。', states: 2, fields: ['length', 'diameter', 'roughness', 'pressure', 'temperature'] },
{ id: 'PNL0003', name: '双端储气管路', sections: ['C', 'R', 'C'], ends: ['out', 'out'], caption: '双端储气', description: '考虑摩擦压降与两端储气容积,包含 4 个热力状态。', states: 4, fields: ['length', 'diameter', 'roughness', 'pressure', 'temperature', 'pressure2', 'temperature2'] },
];
const FIELDS = {
length: { name: '管长', unit: 'm', min: 0, positive: true, default: '' },
diameter: { name: '内径', unit: 'mm', min: 0, positive: true, default: '' },
roughness: { name: '相对粗糙度', unit: '—', min: 0, max: .1, default: '0.00001' },
pressure: { name: '初始压力', unit: 'bar(a)', min: 0, positive: true, default: '1' },
temperature: { name: '初始温度', unit: 'K', min: 0, positive: true, default: '293.15' },
pressure2: { name: '端口 2 初始压力', unit: 'bar(a)', min: 0, positive: true, default: '1' },
temperature2: { name: '端口 2 初始温度', unit: 'K', min: 0, positive: true, default: '293.15' },
};
const modelById = id => MODELS.find(model => model.id === id);
const pipeById = id => PIPES.find(pipe => pipe.id === id);
const initial = () => ({ version: 1, mode: 'submodel', selected: 'L1', configs: Object.fromEntries(PIPES.map(pipe => [pipe.id, { model: null, params: {} }])) });
let state = initial();
let preview = null, storageOK = true, consoleOpen = true;
let pan = null, view = { zoom: 1, x: 0, y: 0 }, baseScale = 1;
let log = [{ kind: 'INFO', text: '选择中间画布的管路,再从左侧指定子模型。' }, { kind: 'INFO', text: '位置和连接关系固定;支持缩放与空白区平移查看。' }];
const activePipe = () => pipeById(state.selected);
const activeConfig = () => state.configs[state.selected];
function compatible(pipe, model) {
const a = endpoints[pipe.a].state, b = endpoints[pipe.b].state;
return (a !== model.ends[0] && b !== model.ends[1]) || (a !== model.ends[1] && b !== model.ends[0]);
}
function candidates(pipe) { return MODELS.filter(model => compatible(pipe, model)); }
function choosePreview() { preview = activeConfig().model || candidates(activePipe())[0]?.id || null; }
function load() {
try {
const data = JSON.parse(localStorage.getItem(KEY) || 'null');
if (!data || data.version !== 1 || !data.configs || typeof data.configs !== 'object') return;
const restored = initial();
if (['modeling', 'submodel', 'parameters', 'check'].includes(data.mode)) restored.mode = data.mode;
if (pipeById(data.selected)) restored.selected = data.selected;
PIPES.forEach(pipe => {
const item = data.configs[pipe.id];
if (!item || !modelById(item.model) || !compatible(pipe, modelById(item.model))) return;
restored.configs[pipe.id] = { model: item.model, params: Object.fromEntries(Object.keys(FIELDS)
.filter(key => ['string', 'number'].includes(typeof item.params?.[key]))
.map(key => [key, String(item.params[key]).slice(0, 64)])) };
});
state = restored;
} catch { storageOK = false; }
}
function save() {
try { localStorage.setItem(KEY, JSON.stringify(state)); storageOK = true; }
catch { storageOK = false; }
$('save-state').textContent = storageOK ? '演示已保存' : '仅当前页面保留';
}
function tell(text) { $('notice').textContent = text; }
function addLog(text, kind = 'INFO') { log = [...log.slice(-15), { kind, text }]; renderLog(); }
function valid(key, value) {
const spec = FIELDS[key], number = Number(value);
return String(value ?? '').trim() !== '' && Number.isFinite(number) && (spec.positive ? number > spec.min : number >= spec.min) && (spec.max === undefined || number <= spec.max);
}
function missing(pipe) {
const config = state.configs[pipe.id];
return (modelById(config.model)?.fields || []).filter(key => !valid(key, config.params[key]));
}
function pipeStatus(pipe) {
const config = state.configs[pipe.id];
return !config.model ? '未指定' : missing(pipe).length ? '待填参数' : '配置完成';
}
function pipeIcon(model) {
const width = 58 / model.sections.length;
return `<svg viewBox="0 0 84 44" aria-hidden="true"><path d="M1 22H13M71 22H83" fill="none" stroke="currentColor" stroke-width="1.5"/><rect x="13" y="10" width="58" height="24" fill="white" stroke="currentColor" stroke-width="1.5"/>${model.sections.map((section, index) => `${index ? `<path d="M${13 + index * width} 10V34" stroke="currentColor" stroke-width="1"/>` : ''}<text x="${13 + width * (index + .5)}" y="26" text-anchor="middle" font-family="Segoe UI,sans-serif" font-size="12" fill="currentColor">${section}</text>`).join('')}</svg>`;
}
function renderLibrary() {
const pipe = activePipe(), editable = state.mode === 'submodel';
const list = $('compatible-only').checked ? candidates(pipe) : MODELS;
$('library').innerHTML = list.map(model => {
const ok = compatible(pipe, model);
return `<button type="button" class="model-tile" data-model="${model.id}" draggable="false" aria-label="${model.id} ${model.name}${ok ? '' : ',不兼容'}" aria-pressed="${preview === model.id}" ${!ok || !editable ? 'disabled' : ''} title="${ok ? model.description : '与当前管路两端的变量供需不匹配'}">${pipeIcon(model)}<strong>${model.id}</strong><small>${model.caption}</small></button>`;
}).join('');
$('library-hint').textContent = editable ? `${pipe.id}:${candidates(pipe).length} 个兼容型号。点击预览,应用后生效。` : '切换到“子模型”模式可更换管路型号。';
$('pipe-list').innerHTML = PIPES.map(item => `<button type="button" data-pipe="${item.id}" aria-label="${item.id} ${pipeStatus(item)}" aria-pressed="${item.id === state.selected}"><span class="state-dot ${state.configs[item.id].model ? missing(item).length ? 'assigned' : 'ready' : ''}"></span><strong>${item.id}</strong><span class="model-id">${state.configs[item.id].model || '未指定'}</span></button>`).join('');
}
function drawFixedComponents() {
const port = (x, y, label = '') => `<circle cx="${x}" cy="${y}" r="4" fill="white" stroke="#b3809a" stroke-width="1.2"/>${label ? `<text class="port-label" x="${x + 9}" y="${y - 10}">${label}</text>` : ''}`;
$('component-layer').innerHTML = `
<g class="fixed-component" data-component="A" transform="translate(140 360)"><rect x="-35" y="-40" width="70" height="80" rx="0" fill="white" stroke="#be8ca5" stroke-width="1.5"/>${port(35, 0)}<text class="component-label" x="0" y="76" text-anchor="middle">储气罐 A</text><text class="component-code" x="0" y="101" text-anchor="middle">PNCH023</text></g>
<g class="fixed-component" data-component="N1" transform="translate(480 360)"><path d="M-20 0H20M0-20V20" stroke="#ae7391" stroke-width="1.6" fill="none"/>${port(-20, 0, '1')}${port(20, 0, '2')}${port(0, -20, '3')}${port(0, 20, '4')}<text class="component-label" x="36" y="63">节点 N1</text><text class="component-code" x="36" y="88">P4NODE2 · 固定</text></g>
<g class="fixed-component" data-component="B" transform="translate(825 360)"><path d="M-35 0H35M-20-17Q0 9 20-17M-20 17Q0-9 20 17M-18 31L18-31" stroke="#be8ca5" stroke-width="1.6" fill="none"/><path d="M18-31L9-23L18-20Z" fill="#be8ca5"/>${port(-35, 0)}<text class="component-label" x="0" y="76" text-anchor="middle">阀门 B</text><text class="component-code" x="0" y="101" text-anchor="middle">PNVO001</text></g>
<g class="fixed-component" data-component="C" transform="translate(480 130)"><path d="M-15-4H15M-12-4L-7-11M-3-4L2-11M6-4L11-11" stroke="#ba829f" stroke-width="1.5" fill="none"/>${port(0, 4)}<text class="component-label" x="30" y="5">封堵 C</text></g>
<g class="fixed-component" data-component="D" transform="translate(480 570)"><path d="M-15 4H15M-12 4L-7 11M-3 4L2 11M6 4L11 11" stroke="#ba829f" stroke-width="1.5" fill="none"/>${port(0, -4)}<text class="component-label" x="30" y="5">封堵 D</text></g>`;
}
function renderWires() {
$('wire-layer').innerHTML = PIPES.map(pipe => `<g><path class="wire ${state.configs[pipe.id].model ? 'assigned' : ''} ${pipe.id === state.selected ? 'selected' : ''}" d="${pipe.path}"/><path class="wire-hit" data-pipe="${pipe.id}" d="${pipe.path}"/></g>`).join('');
const compact = $('canvas').clientWidth < 460;
$('pipe-label-layer').innerHTML = PIPES.map(pipe => `<text class="pipe-name ${pipe.id === state.selected ? 'selected' : ''}" x="${pipe.x}" y="${pipe.y}" text-anchor="${pipe.anchor}">${pipe.id}${!compact && $('show-labels').checked && state.mode !== 'modeling' ? ' · ' + (state.configs[pipe.id].model || '未指定') : ''}</text>`).join('');
$('diagram').classList.toggle('compact', compact);
applyView();
}
function resizeCanvas() {
const width = $('canvas').clientWidth, height = $('canvas').clientHeight;
if (!width || !height) return;
$('diagram').setAttribute('viewBox', `0 0 ${width} ${height}`);
baseScale = Math.max(.1, Math.min((width - 30) / 960, (height - 35) / 660));
renderWires();
}
function applyView() {
const width = $('canvas').clientWidth, height = $('canvas').clientHeight;
const scale = baseScale * view.zoom;
const x = (width - 960 * scale) / 2 + view.x, y = (height - 660 * scale) / 2 + view.y;
$('scene').setAttribute('transform', `translate(${x} ${y}) scale(${scale})`);
$('zoom-value').textContent = `${Math.round(view.zoom * 100)}%`;
const textScale = Math.max(baseScale, .42);
$('scene').querySelectorAll('.component-label,.pipe-name').forEach(label => { label.style.fontSize = `${12 / textScale}px`; });
$('scene').querySelectorAll('.component-code').forEach(label => { label.style.fontSize = `${10 / textScale}px`; });
$('grid-pattern').setAttribute('width', 20 * view.zoom);
$('grid-pattern').setAttribute('height', 20 * view.zoom);
$('grid-pattern').setAttribute('patternTransform', `translate(${view.x} ${view.y})`);
}
function portMapping(pipe, model) {
if (model.ends[0] === model.ends[1]) return `端口 1:${endpoints[pipe.a].label};端口 2:${endpoints[pipe.b].label}。两端接口对称。`;
const forward = endpoints[pipe.a].state !== model.ends[0];
return `端口 1:${endpoints[forward ? pipe.a : pipe.b].label};端口 2:${endpoints[forward ? pipe.b : pipe.a].label}。`;
}
function renderProperties() {
const pipe = activePipe(), config = activeConfig();
const model = modelById(state.mode === 'submodel' ? preview : config.model);
$('selected-pipe').textContent = pipe.id;
const editable = state.mode === 'submodel';
$('model-properties').innerHTML = `<table class="property-table"><tbody>
<tr><th>对象</th><td>管路 ${pipe.id}</td></tr>
<tr><th>连接端 A</th><td>${safe(endpoints[pipe.a].label)}</td></tr>
<tr><th>连接端 B</th><td>${safe(endpoints[pipe.b].label)}</td></tr>
<tr><th>已用型号</th><td>${config.model || '未指定'}</td></tr>
</tbody></table>${model ? `<div class="preview"><div class="preview-title">${pipeIcon(model)}<div><strong>${model.id}</strong><small>${model.name}${editable && model.id !== config.model ? ' · 待应用' : ''}</small></div></div><p class="preview-description">${model.description}</p>
${editable ? `<div class="apply-buttons"><button type="button" class="primary" data-action="apply" ${model.id === config.model ? 'disabled' : ''}>应用到 ${pipe.id}</button><button type="button" data-action="apply-next">应用并下一条</button></div>` : ''}
<details class="property-details"><summary>端口方向与选型说明</summary><p>${safe(portMapping(pipe, model))}</p><p>按接口筛选;储气、压降等物理作用由所选型号决定。</p><p>C 表示储气,R 表示阻力。图标沿用建模区表示方式。</p></details></div>` : '<div class="empty-hint">在“子模型”模式从左侧选择管路型号。</div>'}`;
renderParameters();
}
function renderParameters() {
const config = activeConfig(), model = modelById(config.model), editable = state.mode === 'parameters';
$('edit-parameters').disabled = !model || editable;
if (!model) { $('parameter-properties').innerHTML = '<div class="empty-hint">应用管路子模型后显示所需参数。</div>'; return; }
$('parameter-properties').innerHTML = `<table class="parameter-table"><thead><tr><th>参数</th><th>数值</th><th>单位</th></tr></thead><tbody>${model.fields.map(key => {
const spec = FIELDS[key];
const label = model.id === 'PNL0003' && ['pressure', 'temperature'].includes(key) ? '端口 1 ' + spec.name : spec.name;
return `<tr><td><label for="parameter-${key}">${label}</label></td><td><input id="parameter-${key}" type="number" data-parameter="${key}" value="${safe(config.params[key] ?? '')}" min="${spec.min}" ${spec.max !== undefined ? `max="${spec.max}"` : ''} step="any" ${editable ? '' : 'readonly'} aria-invalid="${editable && !valid(key, config.params[key])}" placeholder="待填"></td><td>${spec.unit}</td></tr>`;
}).join('')}</tbody></table><div class="parameter-note" id="parameter-note"></div>`;
updateParameterNote();
}
function updateParameterNote() {
const note = $('parameter-note');
if (!note) return;
const count = missing(activePipe()).length;
note.classList.toggle('ready', !count);
note.textContent = state.mode !== 'parameters' ? '当前只读;进入参数模式填写实际值。' : count ? `待填写或修正 ${count} 项。` : '参数完整 · 已保存到此演示。';
}
function updateProgress() {
const assigned = PIPES.filter(pipe => state.configs[pipe.id].model).length;
const ready = PIPES.filter(pipe => state.configs[pipe.id].model && !missing(pipe).length).length;
$('progress').textContent = `${assigned}/4 已选型 · ${ready}/4 参数完整`;
}
function renderLog() {
const items = state.mode === 'check' ? PIPES.map(pipe => {
const config = state.configs[pipe.id];
return { kind: !config.model || missing(pipe).length ? 'WARN' : 'OK', text: `${pipe.id} ${config.model || '未指定子模型'} ${pipeStatus(pipe)}`, pipe: !config.model || missing(pipe).length ? pipe.id : null };
}) : log;
$('console-content').innerHTML = items.map(item => `<div class="log-row"><span class="log-kind ${item.kind === 'WARN' ? 'warn' : item.kind === 'OK' ? 'ok' : ''}">${item.kind}</span>${item.pipe ? `<button type="button" data-fix="${item.pipe}">${safe(item.text)} → 处理</button>` : `<span>${safe(item.text)}</span>`}</div>`).join('');
if (state.mode === 'check') {
const complete = items.every(item => item.kind === 'OK');
$('console-summary').textContent = complete ? '局部配置检查通过 · 未执行仿真' : '点击问题项定位';
} else $('console-summary').textContent = '仅配置演示,不执行仿真';
}
function render() {
document.querySelectorAll('[data-mode]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.mode === state.mode)));
$('mode-marker').textContent = ({ modeling: '建模预览', submodel: '子模型模式', parameters: '参数模式', check: '配置检查' })[state.mode];
renderLibrary(); renderWires(); renderProperties(); renderLog(); updateProgress();
}
function setMode(mode) {
state.mode = mode; choosePreview(); render(); save();
tell(mode === 'modeling' ? '此 demo 的部件、位置与连接固定;进入子模型模式选择管路型号。' : mode === 'submodel' ? '选中管路,在左侧预览型号,在右侧应用。' : mode === 'parameters' ? '在右侧填写选中管路的实际参数。' : '当前仅检查演示的接口匹配和必填参数。');
if (mode === 'check') { consoleOpen = true; renderConsoleVisibility(); }
}
function selectPipe(id) {
if (!pipeById(id)) return;
state.selected = id; choosePreview(); render(); save();
tell(`${id} 已选中。部件与连线位置保持锁定。`);
}
function nextUnassigned() {
const start = PIPES.findIndex(pipe => pipe.id === state.selected);
const ordered = [...PIPES.slice(start + 1), ...PIPES.slice(0, start + 1)];
const next = ordered.find(pipe => !state.configs[pipe.id].model);
if (next) { setMode('submodel'); selectPipe(next.id); }
else tell('所有管路均已选型。可进入参数模式继续填写。');
}
function applyModel(next = false) {
if (state.mode !== 'submodel') return;
const pipe = activePipe(), model = modelById(preview), config = activeConfig();
if (!model || !compatible(pipe, model)) return;
config.model = model.id;
model.fields.forEach(key => { if (config.params[key] === undefined) config.params[key] = FIELDS[key].default; });
render(); save(); addLog(`${pipe.id} 已应用 ${model.id}。${model.description}`);
tell(`${pipe.id} 已应用 ${model.id}。`);
if (next) {
if (PIPES.some(item => !state.configs[item.id].model)) nextUnassigned();
else { const target = PIPES.find(item => missing(item).length); if (target) state.selected = target.id; setMode('parameters'); tell('管路选型完成;填写实际管长、内径后检查配置。'); }
}
}
function zoom(factor, anchor = null) {
const next = Math.max(.5, Math.min(3, view.zoom * factor));
if (anchor) {
const width = $('canvas').clientWidth, height = $('canvas').clientHeight;
const ratio = next / view.zoom;
view.x = anchor.x - width / 2 - (anchor.x - width / 2 - view.x) * ratio;
view.y = anchor.y - height / 2 - (anchor.y - height / 2 - view.y) * ratio;
}
view.zoom = next; applyView();
}
function renderConsoleVisibility() {
$('console').classList.toggle('collapsed', !consoleOpen);
$('toggle-console').setAttribute('aria-expanded', String(consoleOpen));
$('toggle-console').textContent = `${consoleOpen ? '▾' : '▸'} 配置信息`;
}
document.querySelectorAll('[data-mode]').forEach(button => button.addEventListener('click', () => setMode(button.dataset.mode)));
$('pipe-list').addEventListener('click', event => { const id = event.target.closest('[data-pipe]')?.dataset.pipe; if (id) selectPipe(id); });
$('diagram').addEventListener('click', event => {
if (pan?.moved) return;
const pipe = event.target.closest('[data-pipe]');
if (pipe) selectPipe(pipe.dataset.pipe);
else if (event.target.closest('[data-component]')) tell('此 demo 只开放管路子模型,部件与 pnode 固定。');
});
$('diagram').addEventListener('dblclick', event => { const id = event.target.closest('[data-pipe]')?.dataset.pipe; if (id) { setMode('submodel'); selectPipe(id); $('library').querySelector('button:not(:disabled)')?.focus(); } });
$('library').addEventListener('click', event => {
const target = event.target.closest('[data-model]');
if (!target || target.disabled || state.mode !== 'submodel') return;
preview = target.dataset.model; renderLibrary(); renderProperties();
tell(`预览 ${preview};点击“应用到 ${state.selected}”生效。`);
});
$('library').addEventListener('dblclick', event => { const target = event.target.closest('[data-model]'); if (target && !target.disabled) { preview = target.dataset.model; applyModel(); } });
$('compatible-only').addEventListener('change', renderLibrary);
$('show-labels').addEventListener('change', renderWires);
$('next-unassigned').addEventListener('click', nextUnassigned);
$('model-properties').addEventListener('click', event => { const action = event.target.closest('[data-action]')?.dataset.action; if (action === 'apply' || action === 'apply-next') applyModel(action === 'apply-next'); });
$('edit-parameters').addEventListener('click', () => setMode('parameters'));
$('parameter-properties').addEventListener('input', event => {
if (state.mode !== 'parameters') return;
const key = event.target.dataset.parameter;
if (!FIELDS[key]) return;
activeConfig().params[key] = event.target.value;
event.target.setAttribute('aria-invalid', String(!valid(key, event.target.value)));
updateParameterNote(); updateProgress(); renderLibrary(); save();
});
$('console-content').addEventListener('click', event => {
const id = event.target.closest('[data-fix]')?.dataset.fix;
if (id) { selectPipe(id); setMode(state.configs[id].model ? 'parameters' : 'submodel'); }
});
$('toggle-console').addEventListener('click', () => { consoleOpen = !consoleOpen; renderConsoleVisibility(); });
$('fit-view').addEventListener('click', () => { view = { zoom: 1, x: 0, y: 0 }; applyView(); });
$('zoom-in').addEventListener('click', () => zoom(1.2));
$('zoom-out').addEventListener('click', () => zoom(1 / 1.2));
$('diagram').addEventListener('wheel', event => { event.preventDefault(); const bounds = $('diagram').getBoundingClientRect(); zoom(event.deltaY < 0 ? 1.1 : 1 / 1.1, { x: event.clientX - bounds.left, y: event.clientY - bounds.top }); }, { passive: false });
// Only viewport movement is supported. No component/port/edge drag handler exists.
$('diagram').addEventListener('pointerdown', event => {
if (event.button !== 1 && !(event.button === 0 && event.target.hasAttribute('data-background'))) return;
event.preventDefault();
pan = { id: event.pointerId, x: event.clientX, y: event.clientY, initialX: view.x, initialY: view.y, moved: false };
$('diagram').setPointerCapture(event.pointerId); $('diagram').classList.add('panning');
});
$('diagram').addEventListener('pointermove', event => {
if (!pan || pan.id !== event.pointerId) return;
const dx = event.clientX - pan.x, dy = event.clientY - pan.y;
pan.moved ||= Math.abs(dx) + Math.abs(dy) > 3;
view.x = pan.initialX + dx; view.y = pan.initialY + dy; applyView();
});
function endPan(event) {
if (!pan || pan.id !== event.pointerId) return;
if ($('diagram').hasPointerCapture(event.pointerId)) $('diagram').releasePointerCapture(event.pointerId);
$('diagram').classList.remove('panning'); pan = null;
}
$('diagram').addEventListener('pointerup', endPan);
$('diagram').addEventListener('pointercancel', endPan);
$('diagram').addEventListener('lostpointercapture', endPan);
document.addEventListener('dragstart', event => { if (event.target.closest('#diagram,#library')) event.preventDefault(); });
document.addEventListener('keydown', event => {
if (event.key === 'Delete' && !event.target.closest('input,textarea,[contenteditable=true]')) { event.preventDefault(); tell('子模型演示的部件和连接固定,不能删除或移动。'); }
});
$('reset').addEventListener('click', () => {
state = initial(); view = { zoom: 1, x: 0, y: 0 }; pan = null; choosePreview();
log = [{ kind: 'INFO', text: '已重置本页的管路选型与参数。固定布局保持原样。' }];
render(); save(); tell('已重置此 demo;上一版和正式工程不受影响。');
});
load(); choosePreview(); drawFixedComponents(); render(); save();
new ResizeObserver(resizeCanvas).observe($('canvas'));
})();
@@ -0,0 +1,77 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>管路子模型 · System Simulation</title>
<link rel="stylesheet" href="./styles.css">
<script src="./app.js" defer></script>
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div class="product"><strong>System Simulation</strong><span>管路子模型布局演示</span></div>
<nav class="view-tabs" aria-label="工作模式">
<button data-mode="modeling" type="button" aria-pressed="false">建模</button>
<button data-mode="submodel" type="button" aria-pressed="true">子模型</button>
<button data-mode="parameters" type="button" aria-pressed="false">参数</button>
<button data-mode="check" type="button" aria-pressed="false">检查</button>
</nav>
<div class="topbar-actions"><a href="../amesim-submodels/index.html">上一版</a><a href="/">主应用</a><button id="reset" type="button">重置演示</button></div>
</header>
<nav class="editor-toolbar" aria-label="子模型工具">
<span class="mode-marker" id="mode-marker">子模型模式</span>
<span class="toolbar-separator"></span>
<button id="next-unassigned" type="button"><span aria-hidden="true">↦</span> 下一条待选</button>
<button id="fit-view" type="button">适配视图</button>
<label class="toolbar-check"><input type="checkbox" id="show-labels" checked>显示子模型名称</label>
<span class="toolbar-end"><svg width="13" height="14" viewBox="0 0 13 14" aria-hidden="true"><rect x="2" y="6" width="9" height="7" rx="1"/><path d="M4 6V4a2.5 2.5 0 0 1 5 0v2"/></svg>布局已锁定</span>
</nav>
<main class="workbench">
<aside class="palette" aria-label="管路子模型库">
<div class="panel-title"><strong>管路子模型</strong><span>4 个</span></div>
<div class="palette-content">
<label class="compat-filter"><input id="compatible-only" type="checkbox" checked>只显示当前连线兼容项</label>
<div class="library-heading">▾ 气动管路</div>
<div id="library" class="palette-grid" aria-label="可选管路子模型"></div>
<div id="library-hint" class="library-hint">先选中画布中的管路。</div>
</div>
<div class="section-heading">模型中的管路</div>
<div id="pipe-list" class="pipe-list" aria-label="管路列表"></div>
<div class="palette-footer">仅管路可选型<br>pnode 与其他部件固定</div>
</aside>
<div class="pane-divider" aria-hidden="true"></div>
<section class="center-pane" aria-label="模型画布与检查信息">
<div class="document-tab"><span>气动管路示例</span><span class="document-note">固定连接 · 独立 demo</span></div>
<div id="canvas" class="canvas">
<svg id="diagram" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="固定气动网络,选择管路可指定子模型,部件位置不可修改">
<defs><pattern id="grid-pattern" width="20" height="20" patternUnits="userSpaceOnUse"><circle cx="1" cy="1" r="0.75" fill="#c8d1da"/></pattern></defs>
<rect width="100%" height="100%" fill="white" data-background="true"/>
<rect width="100%" height="100%" fill="url(#grid-pattern)" data-background="true"/>
<g id="scene"><g id="wire-layer"></g><g id="component-layer"></g><g id="pipe-label-layer"></g></g>
</svg>
<div class="canvas-lock">部件 / 连线位置锁定</div>
<div class="viewport-controls"><button type="button" id="zoom-out" aria-label="缩小画布">−</button><span id="zoom-value">100%</span><button type="button" id="zoom-in" aria-label="放大画布">+</button></div>
</div>
<section id="console" class="console" aria-label="配置检查信息">
<div class="console-header"><button id="toggle-console" type="button" aria-expanded="true">▾ 配置信息</button><span id="console-summary">仅配置演示,不执行仿真</span></div>
<div id="console-content" class="console-content"></div>
</section>
</section>
<div class="pane-divider" aria-hidden="true"></div>
<aside class="properties" aria-label="子模型属性与参数">
<section class="properties-subpane">
<div class="panel-title"><strong>子模型属性</strong><span id="selected-pipe">L1</span></div>
<div id="model-properties" class="properties-content"></div>
</section>
<div class="horizontal-divider" aria-hidden="true"></div>
<section class="properties-subpane">
<div class="panel-title"><strong>管路参数</strong><button id="edit-parameters" type="button">进入参数模式</button></div>
<div id="parameter-properties" class="properties-content"></div>
</section>
</aside>
</main>
<footer class="statusbar"><span id="notice" role="status" aria-live="polite">选择管路,再从左侧指定子模型。</span><span id="progress"></span><span id="save-state">独立保存</span></footer>
</div>
</body>
</html>
@@ -0,0 +1,126 @@
:root { font: 13px/1.5 Inter, "Segoe UI", "Microsoft YaHei", sans-serif; color: #263442; background: #eef2f6; --blue: #1d6fb8; --border: #cfd8e3; --muted: #64748b; --gas: #8b134f; }
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; height: 100%; }
button, input { font: inherit; }
button { cursor: pointer; border: 1px solid #c2ccda; background: #fff; color: #263442; padding: 5px 10px; border-radius: 4px; }
button:hover { background: #eff5fa; }
button:disabled { cursor: default; opacity: .48; }
button:focus-visible, input:focus-visible, a:focus-visible, summary:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
a { color: #155a94; text-decoration: none; }
input[type=checkbox] { accent-color: var(--blue); }
.app-shell { height: 100dvh; display: grid; grid-template-rows: 62px 42px minmax(0,1fr) 28px; }
.topbar { display: grid; grid-template-columns: minmax(220px,1fr) auto minmax(240px,1fr); align-items: center; gap: 16px; padding: 8px 14px; border-bottom: 1px solid var(--border); background: #fff; }
.product strong { display: block; font-size: 15px; }
.product span { display: block; margin-top: 2px; font-size: 12px; color: var(--muted); }
.view-tabs { display: flex; height: 34px; border-bottom: 1px solid #bfcbd8; }
.view-tabs button { min-width: 68px; padding: 0 13px; border: 0; border-radius: 0; border-bottom: 2px solid transparent; color: #526172; background: transparent; }
.view-tabs button[aria-pressed=true] { border-bottom-color: var(--blue); color: #155a94; background: #eaf3fa; font-weight: 600; }
.topbar-actions { display: flex; justify-content: flex-end; align-items: center; gap: 13px; font-size: 12px; }
.editor-toolbar { display: flex; gap: 9px; align-items: center; padding: 5px 12px; border-bottom: 1px solid var(--border); background: #f5f7f9; }
.editor-toolbar button { font-size: 12px; background: transparent; border-color: transparent; }
.editor-toolbar button:hover { border-color: #c2ccda; background: #fff; }
.mode-marker { color: #155a94; font-size: 12px; font-weight: 600; }
.toolbar-separator { height: 22px; width: 1px; background: #d0d9e2; margin: 0 3px; }
.toolbar-check { display: flex; align-items: center; gap: 5px; font-size: 12px; cursor: pointer; }
.toolbar-end { margin-left: auto; display: flex; gap: 6px; align-items: center; font-size: 12px; color: var(--muted); }
.toolbar-end svg { stroke: currentColor; stroke-width: 1.2; fill: none; }
.workbench { display: grid; grid-template-columns: 230px 7px minmax(0,1fr) 7px 330px; min-height: 0; }
.palette { display: flex; flex-direction: column; min-height: 0; overflow: auto; background: #fff; }
.panel-title { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 0 10px; min-height: 33px; background: #f2f4f6; border-bottom: 1px solid #c7cdd4; font-size: 12px; }
.panel-title span { font-size: 11px; color: var(--muted); }
.panel-title button { font-size: 11px; border: 0; background: transparent; padding: 3px; color: #155a94; }
.palette > .panel-title { background: #fff; padding: 12px 14px; min-height: 49px; border-bottom: 1px solid #e3e8ef; font-size: 13px; }
.palette-content { padding: 12px; }
.compat-filter { display: flex; align-items: center; gap: 4px; font-size: 11px; color: #526172; cursor: pointer; margin-bottom: 15px; }
.library-heading { color: #526172; font-size: 12px; margin-bottom: 10px; font-weight: 600; }
.palette-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; }
.model-tile { display: grid; grid-template-rows: 39px 18px 15px; gap: 2px; padding: 7px 5px; place-items: center; min-width: 0; border: 1px solid #d6dee9; background: #fff; border-radius: 5px; }
.model-tile svg { width: 70px; height: 37px; color: var(--gas); }
.model-tile strong { font-size: 11px; line-height: 18px; }
.model-tile small { color: #66788c; font-size: 10px; line-height: 15px; }
.model-tile[aria-pressed=true] { border-color: #4f92c7; background: #eaf4fc; box-shadow: inset 0 0 0 1px #4f92c7; }
.model-tile:disabled { opacity: .4; background: #f5f6f7; }
.library-hint { font-size: 11px; color: var(--muted); margin-top: 12px; min-height: 33px; }
.section-heading { padding: 9px 13px; border-top: 1px solid #e3e8ef; border-bottom: 1px solid #e3e8ef; font-size: 12px; color: #475a6e; background: #f8fafc; }
.pipe-list { padding: 7px 0; }
.pipe-list button { display: grid; grid-template-columns: 8px 27px minmax(0,1fr); align-items: center; gap: 7px; width: 100%; border: 0; border-radius: 0; text-align: left; padding: 9px 14px; font-size: 12px; }
.pipe-list button[aria-pressed=true] { color: #155a94; background: #eaf3fa; }
.pipe-list .model-id { justify-self: end; font-size: 11px; color: #63788b; }
.state-dot { width: 6px; height: 6px; border-radius: 50%; background: #c3904f; }
.state-dot.assigned { background: #628594; }
.state-dot.ready { background: #398267; }
.palette-footer { margin-top: auto; padding: 15px 13px; color: #738295; font-size: 11px; border-top: 1px solid #e5ebf0; }
.pane-divider { background: linear-gradient(90deg,#eef2f6 3px,#c7d1dc 3px,#c7d1dc 4px,#eef2f6 4px); }
.horizontal-divider { height: 7px; background: linear-gradient(#eef2f6 3px,#c7d1dc 3px,#c7d1dc 4px,#eef2f6 4px); }
.center-pane { min-width: 0; min-height: 0; display: grid; grid-template-rows: 32px minmax(0,1fr) auto; }
.document-tab { display: flex; justify-content: space-between; align-items: center; background: #f2f5f8; border-bottom: 1px solid #cbd5df; font-size: 12px; }
.document-tab > span:first-child { background: #fff; height: 100%; padding: 7px 16px; border-right: 1px solid #cbd5df; }
.document-note { padding-right: 11px; color: #8290a0; font-size: 11px; }
.canvas { min-width: 0; min-height: 0; position: relative; overflow: hidden; background: #fff; }
#diagram { display: block; width: 100%; height: 100%; user-select: none; touch-action: none; }
#diagram [data-background] { cursor: grab; }
#diagram.panning [data-background] { cursor: grabbing; }
.fixed-component { cursor: default; }
.fixed-component path, .fixed-component rect, .fixed-component circle { vector-effect: non-scaling-stroke; }
.component-label { font: 15px "Segoe UI", "Microsoft YaHei", sans-serif; fill: #344759; }
.component-code { font: 11px "Segoe UI", sans-serif; fill: #8a97a4; }
.port-label { font: 11px "Segoe UI", sans-serif; fill: #8b134f; }
.wire { fill: none; stroke: #b66b92; stroke-width: 1.6; stroke-dasharray: 5 4; vector-effect: non-scaling-stroke; pointer-events: none; }
.wire.assigned { stroke: #8b134f; stroke-dasharray: none; }
.wire.selected { stroke: #176eb0; stroke-width: 2.6; }
.wire-hit { fill: none; stroke: transparent; stroke-width: 20; vector-effect: non-scaling-stroke; cursor: pointer; }
.pipe-name { font: 13px "Segoe UI", "Microsoft YaHei", sans-serif; fill: #8b134f; paint-order: stroke; stroke: white; stroke-width: 5px; stroke-linejoin: round; pointer-events: none; }
.pipe-name.selected { fill: #155a94; }
.canvas-lock { position: absolute; top: 10px; right: 12px; font-size: 10px; color: #8a97a5; pointer-events: none; }
.viewport-controls { display: flex; align-items: center; position: absolute; left: 12px; bottom: 12px; border: 1px solid #cbd5e1; background: white; border-radius: 4px; box-shadow: 0 2px 5px #40526910; }
.viewport-controls button { padding: 2px 9px; border: 0; font-size: 18px; }
.viewport-controls span { font-size: 11px; color: #536b7f; min-width: 44px; text-align: center; }
.console { background: #fff; border-top: 1px solid #c7d1dc; min-height: 31px; }
.console-header { display: flex; gap: 10px; align-items: center; min-height: 31px; background: #f3f5f8; border-bottom: 1px solid #dfe5ec; padding-right: 11px; }
.console-header button { font-size: 12px; background: transparent; border: 0; text-align: left; border-radius: 0; }
.console-header span { margin-left: auto; color: #7f8d9b; font-size: 10px; }
.console-content { height: 120px; padding: 9px 12px; overflow: auto; font-size: 11px; color: #5e7286; }
.console.collapsed .console-content { display: none; }
.log-row { display: flex; align-items: baseline; gap: 10px; padding: 4px 0; }
.log-kind { font-size: 10px; color: #6a8090; flex: 0 0 30px; }
.log-kind.warn { color: #956b28; }
.log-kind.ok { color: #388166; }
.log-row button { font-size: 11px; padding: 0; border: 0; color: #1764a0; background: transparent; text-align: left; }
.properties { display: grid; grid-template-rows: minmax(290px, 46%) 7px minmax(0,1fr); min-width: 0; min-height: 0; }
.properties-subpane { display: grid; grid-template-rows: 33px minmax(0,1fr); min-width: 0; min-height: 0; background: #fff; }
.properties-content { min-height: 0; overflow: auto; }
.property-table, .parameter-table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 12px; }
.property-table th, .property-table td, .parameter-table td, .parameter-table th { text-align: left; vertical-align: middle; padding: 7px 9px; border-bottom: 1px solid #e3e8ee; overflow-wrap: anywhere; }
.property-table th { width: 76px; font-weight: 400; color: #687c8e; background: #f8fafc; }
.preview { padding: 13px 11px 10px; }
.preview-title { display: flex; align-items: center; gap: 10px; }
.preview-title svg { width: 72px; height: 38px; color: var(--gas); }
.preview-title strong { font-size: 13px; }
.preview-title small { display: block; color: #7f8e9d; font-size: 11px; }
.preview-description { font-size: 11px; color: #62768a; margin: 7px 0 9px; }
.apply-buttons { display: flex; gap: 7px; flex-wrap: wrap; }
.apply-buttons button { font-size: 12px; }
.primary { background: var(--blue); border-color: var(--blue); color: white; }
.primary:hover { background: #155a94; }
.property-details { font-size: 11px; color: #6a7d8f; margin: 11px 0 0; }
summary { cursor: pointer; }
.property-details p { margin: 7px 0; }
.empty-hint { padding: 15px 12px; color: #7d8b99; font-size: 12px; }
.parameter-table th { background: #f2f4f6; color: #536576; font-size: 11px; font-weight: 400; }
.parameter-table th:nth-child(1) { width: 46%; }
.parameter-table th:nth-child(2) { width: 34%; }
.parameter-table th:nth-child(3) { width: 20%; }
.parameter-table td { padding: 3px 7px; height: 34px; }
.parameter-table td:first-child { color: #435b70; font-size: 11px; }
.parameter-table td:last-child { color: #7c8d9e; font-size: 10px; }
.parameter-table input { width: 100%; min-width: 0; height: 26px; color: #293f52; padding: 3px 5px; border: 1px solid #c9d4df; border-radius: 2px; font-size: 12px; background: white; }
.parameter-table input:read-only { background: #f7f9fb; color: #7b8a98; border-color: transparent; }
.parameter-table input[aria-invalid=true] { border-color: #c39450; }
.parameter-note { font-size: 11px; color: #7d8d9b; padding: 10px; }
.parameter-note.ready { color: #398267; }
.statusbar { display: flex; align-items: center; gap: 16px; padding: 0 12px; font-size: 11px; background: #f5f7f9; border-top: 1px solid #cbd5df; color: #647b8f; }
#notice { margin-right: auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#progress, #save-state { flex-shrink: 0; }
@media(max-width:1150px) { .workbench { grid-template-columns: 195px 7px minmax(0,1fr) 7px 280px; } .topbar { grid-template-columns: minmax(185px,1fr) auto minmax(180px,1fr); gap: 8px; } .view-tabs button { min-width: 56px; padding: 0 10px; } .topbar-actions { gap: 9px; } .palette-content { padding: 10px; } .model-tile svg { width: 60px; } }
@media(max-width:850px) { .app-shell { height: auto; min-height: 100dvh; grid-template-rows: auto auto 1fr auto; } .topbar { grid-template-columns: 1fr auto; padding: 10px; } .view-tabs { grid-row: 2; grid-column: 1 / -1; justify-content: center; margin-top: 6px; } .view-tabs button { flex: 1; } .editor-toolbar { flex-wrap: wrap; min-height: 42px; } .toolbar-end { margin-left: auto; } .workbench { grid-template-columns: 175px 5px minmax(0,1fr); grid-template-rows: 530px auto; } .workbench > .pane-divider:nth-of-type(2) { display: none; } .properties { grid-column: 1 / -1; grid-template-columns: 1fr 7px 1fr; grid-template-rows: 350px; border-top: 1px solid #cbd5df; } .horizontal-divider { height: 100%; background: #eef2f6; } .properties-subpane { height: 100%; } .palette { min-height: 0; } .document-note { display: none; } .console-header span { display: none; } .console-content { height: 95px; } .palette-content { padding: 8px; } .palette-footer { font-size: 10px; } .statusbar { min-height: 28px; flex-wrap: wrap; gap: 5px 10px; padding: 5px 10px; } #notice { flex: 1 1 100%; } }
@media(max-width:520px) { .topbar-actions { gap: 8px; font-size: 11px; } .topbar-actions button { padding: 4px 6px; } .product strong { font-size: 13px; } .product span { font-size: 10px; } .toolbar-check { margin-right: auto; } .toolbar-end { font-size: 10px; } .workbench { display: flex; flex-direction: column; } .workbench > .pane-divider { display: none; } .palette { display: grid; grid-template-columns: 1fr; overflow: visible; } .palette-content { padding: 10px; } .palette-grid { grid-template-columns: repeat(4,minmax(0,1fr)); gap: 5px; } .model-tile svg { width: 54px; } .model-tile small { font-size: 9px; } .palette-footer, .library-heading, .palette .section-heading { display: none; } .compat-filter { margin-bottom: 8px; } .library-hint { margin-top: 7px; min-height: 0; } .pipe-list { display: flex; padding: 0; } .pipe-list button { display: flex; gap: 5px; padding: 8px; } .pipe-list .model-id { display: none; } .center-pane { height: 450px; } .properties { display: flex; flex-direction: column; } .properties-subpane { min-height: 310px; max-height: none; overflow: visible; } .properties-content { overflow: visible; } .horizontal-divider { height: 7px; } }
+375 -251
View File
File diff suppressed because it is too large. Load diff
+10 -24
View File
@@ -1,9 +1,8 @@
import { Background, BackgroundVariant } from "@xyflow/react";
export const CANVAS_GRID_SIZE = 18;
const CANVAS_DOT_GAP = 18;
export type CanvasGridVisibility = {
lines: boolean;
dots: boolean;
};
@@ -14,26 +13,13 @@ type CanvasGridBackgroundProps = {
export function CanvasGridBackground({
visibility,
}: CanvasGridBackgroundProps) {
return (
<>
{visibility.lines ? (
<Background
color="#d6e0ea"
gap={CANVAS_GRID_SIZE * 5}
id="major-grid-lines"
lineWidth={1}
variant={BackgroundVariant.Lines}
/>
) : null}
{visibility.dots ? (
<Background
color="#c4d0dd"
gap={CANVAS_GRID_SIZE}
id="minor-grid-dots"
size={1}
variant={BackgroundVariant.Dots}
/>
) : null}
</>
);
return visibility.dots ? (
<Background
color="#c4d0dd"
gap={CANVAS_DOT_GAP}
id="minor-grid-dots"
size={1}
variant={BackgroundVariant.Dots}
/>
) : null;
}
+102 -116
View File
@@ -53,8 +53,15 @@ import {
import {
continuousMechanicalForceChartValues,
resultStateTransitionSampleIndices,
segmentedChartPath,
} from "./resultEventSeries";
import {
preparedChartSamples,
chartCursorSamples,
chartSampleBounds,
combinedChartBounds,
visibleSampleRange,
sampledChartPath,
} from "./chartData";
type ResultPortDefinition = {
name: string;
@@ -401,6 +408,28 @@ export function SimulationResultsView({
),
[snapshot.result.diagnostics, snapshot.result.series.time],
);
const displaySeriesFor = useMemo(() => {
const cache = new Map<string, { values: number[]; rawValues: number[]; separatedEventSampleCount: number }>();
const components = new Map(snapshot.project.nodes.map((node) => [node.id, node]));
return (variable: ResultVariableMetadata, unit: ResultUnitOption) => {
const key = `${variable.key}:${unit.value}`;
const previous = cache.get(key);
if (previous) return previous;
const raw = snapshot.result.series[variable.key] ?? [];
const display = continuousMechanicalForceChartValues(variable, components.get(variable.componentId), raw, stateTransitionSampleIndices);
const identityUnit = unit.fromBase(0) === 0 && unit.fromBase(1) === 1;
const rawValues = identityUnit ? raw : raw.map(unit.fromBase);
const entry = {
values: display.values === raw ? rawValues : identityUnit ? display.values : display.values.map(unit.fromBase),
rawValues,
separatedEventSampleCount: display.separatedEventSampleCount,
};
// Bound unit-switch history; immutable arrays held by open charts remain valid.
if (cache.size >= 64) cache.delete(cache.keys().next().value!);
cache.set(key, entry);
return entry;
};
}, [snapshot, stateTransitionSampleIndices]);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(
snapshot.project.nodes[0]?.id ?? null,
);
@@ -1632,15 +1661,8 @@ export function SimulationResultsView({
chartWindow.unit,
);
const chartTime = snapshot.result.series.time ?? [];
const primaryChartValues = continuousMechanicalForceChartValues(
variable,
component,
snapshot.result.series[chartWindow.variableKey] ?? [],
stateTransitionSampleIndices,
);
const displayValues = primaryChartValues.values.map(
selectedUnit.fromBase,
);
const primaryChartValues = displaySeriesFor(variable, selectedUnit);
const displayValues = primaryChartValues.values;
const combinedVariables = windowVariables.filter(
(item) => isMixed || item.unit === variable.unit,
);
@@ -1657,13 +1679,7 @@ export function SimulationResultsView({
chartWindow.variableUnits[item.key] ?? item.unit,
)
: selectedUnit;
const itemChartValues =
continuousMechanicalForceChartValues(
item,
itemComponent,
snapshot.result.series[item.key] ?? [],
stateTransitionSampleIndices,
);
const itemChartValues = displaySeriesFor(item, itemUnit);
return {
key: item.key,
label: resultVariableTitle(
@@ -1672,7 +1688,8 @@ export function SimulationResultsView({
),
color: colorForVariable(item.key),
unit: itemUnit.label,
values: itemChartValues.values.map(itemUnit.fromBase),
values: itemChartValues.values,
cursorValues: itemChartValues.rawValues,
separatedEventSampleCount:
itemChartValues.separatedEventSampleCount,
};
@@ -2098,6 +2115,7 @@ export function SimulationResultsView({
title={chartTitle}
unit={selectedUnit.label}
values={displayValues}
cursorValues={primaryChartValues.rawValues}
viewport={chartWindow.viewport}
zoomEnabled={chartWindow.zoomEnabled}
/>
@@ -2310,6 +2328,7 @@ type MultiCurveSeries = {
color: string;
unit: string;
values: number[];
cursorValues?: number[];
separatedEventSampleCount: number;
};
@@ -2990,9 +3009,7 @@ function chartDomainForViewport(
return [minimum + span * viewport.start, minimum + span * viewport.end];
}
function paddedChartDomain(values: number[]): [number, number] {
const rawMinimum = Math.min(...values);
const rawMaximum = Math.max(...values);
function paddedChartDomain(rawMinimum: number, rawMaximum: number): [number, number] {
const padding =
rawMinimum === rawMaximum
? Math.max(Math.abs(rawMinimum) * 0.05, 1)
@@ -3039,31 +3056,35 @@ function chartSegmentIntersectsDomain(
}
function chartSamplesHaveVisibleSegment(
samples: Array<{ x: number; y: number }>,
samples: Array<{ x: number; y: number; breakBefore?: boolean }>,
xMinimum: number,
xMaximum: number,
yMinimum: number,
yMaximum: number,
) {
return samples.some((sample, index) => {
const [start, end] = visibleSampleRange(samples, xMinimum, xMaximum);
for (let index = start; index < end; index++) {
const sample = samples[index];
const previous = samples[index - 1];
if (!previous) {
return (
if (!previous || sample.breakBefore) {
if (
sample.x >= xMinimum &&
sample.x <= xMaximum &&
sample.y >= yMinimum &&
sample.y <= yMaximum
);
) return true;
continue;
}
return chartSegmentIntersectsDomain(
if (chartSegmentIntersectsDomain(
previous,
sample,
xMinimum,
xMaximum,
yMinimum,
yMaximum,
);
});
)) return true;
}
return false;
}
function chartSeriesHasVisibleSegments(
@@ -3075,22 +3096,15 @@ function chartSeriesHasVisibleSegments(
const prepared = series
.map((curve) => ({
...curve,
samples: time
.map((timeValue, index) => ({
x: Number(timeValue),
y: Number(curve.values[index]),
}))
.filter(
(sample) => Number.isFinite(sample.x) && Number.isFinite(sample.y),
),
samples: preparedChartSamples(time, curve.values),
}))
.filter((curve) => curve.samples.length >= 2);
if (prepared.length === 0) {
return false;
}
const allSamples = prepared.flatMap((curve) => curve.samples);
const fullXMinimum = Math.min(...allSamples.map((sample) => sample.x));
const fullXMaximum = Math.max(...allSamples.map((sample) => sample.x));
const bounds = combinedChartBounds(prepared);
const fullXMinimum = bounds.xMin;
const fullXMaximum = bounds.xMax;
const [xMinimum, xMaximum] = chartDomainForViewport(
fullXMinimum,
fullXMaximum,
@@ -3098,9 +3112,8 @@ function chartSeriesHasVisibleSegments(
);
if (stacked) {
return prepared.some((curve) => {
const [fullYMinimum, fullYMaximum] = paddedChartDomain(
curve.samples.map((sample) => sample.y),
);
const curveBounds = chartSampleBounds(curve.samples);
const [fullYMinimum, fullYMaximum] = paddedChartDomain(curveBounds.yMin, curveBounds.yMax);
const [yMinimum, yMaximum] = chartDomainForViewport(
fullYMinimum,
fullYMaximum,
@@ -3116,7 +3129,7 @@ function chartSeriesHasVisibleSegments(
});
}
const [fullYMinimum, fullYMaximum] = paddedChartDomain(
allSamples.map((sample) => sample.y),
bounds.yMin, bounds.yMax,
);
const [yMinimum, yMaximum] = chartDomainForViewport(
fullYMinimum,
@@ -3167,28 +3180,13 @@ function MultiCurvePlot({
series
.map((curve) => ({
...curve,
samples: time
.map((timeValue, index) => ({
x: Number(timeValue),
y: Number(curve.values[index]),
dataIndex: index,
}))
.filter(
(sample) =>
Number.isFinite(sample.x) && Number.isFinite(sample.y),
),
samples: preparedChartSamples(time, curve.values),
}))
.filter((curve) => curve.samples.length >= 2),
[series, time],
);
const cursorSamples = useMemo<CurveSample[]>(
() =>
time
.map((timeValue, index) => ({
x: Number(timeValue),
y: index,
}))
.filter((sample) => Number.isFinite(sample.x)),
() => chartCursorSamples(time),
[time],
);
@@ -3303,9 +3301,9 @@ function MultiCurvePlot({
);
}
const allSamples = plottedSeries.flatMap((curve) => curve.samples);
const rawYMin = Math.min(...allSamples.map((sample) => sample.y));
const rawYMax = Math.max(...allSamples.map((sample) => sample.y));
const bounds = combinedChartBounds(plottedSeries);
const rawYMin = bounds.yMin;
const rawYMax = bounds.yMax;
const sharedYPadding =
rawYMin === rawYMax
? Math.max(Math.abs(rawYMin) * 0.05, 1)
@@ -3320,8 +3318,8 @@ function MultiCurvePlot({
viewport.y,
);
const sharedYScale = { minimum: yMin, maximum: yMax };
const fullXMin = Math.min(...allSamples.map((sample) => sample.x));
const fullXMax = Math.max(...allSamples.map((sample) => sample.x));
const fullXMin = bounds.xMin;
const fullXMax = bounds.xMax;
const [xMin, xMax] = chartDomainForViewport(fullXMin, fullXMax, viewport.x);
const xRange = Math.max(xMax - xMin, 1e-12);
const xPosition = (value: number) =>
@@ -3353,7 +3351,7 @@ function MultiCurvePlot({
index,
label: curve.label,
unit: curve.unit,
y: Number(curve.values[Math.round(cursorSample.y)]),
y: Number((curve.cursorValues ?? curve.values)[Math.round(cursorSample.y)]),
}))
.filter((reading) => Number.isFinite(reading.y))
: [];
@@ -3641,11 +3639,12 @@ function MultiCurvePlot({
/>
<g clipPath={`url(#${clipPathId})`} data-chart-clipped-series="true">
{plottedSeries.map((curve) => {
const path = segmentedChartPath(
const path = sampledChartPath(
time,
curve.values,
xPosition,
yPosition,
xMin, xMax, plotWidth,
);
return (
<path
@@ -3824,28 +3823,13 @@ function StackedCurvePlot({
series
.map((curve) => ({
...curve,
samples: time
.map((timeValue, index) => ({
x: Number(timeValue),
y: Number(curve.values[index]),
dataIndex: index,
}))
.filter(
(sample) =>
Number.isFinite(sample.x) && Number.isFinite(sample.y),
),
samples: preparedChartSamples(time, curve.values),
}))
.filter((curve) => curve.samples.length >= 2),
[series, time],
);
const cursorSamples = useMemo<CurveSample[]>(
() =>
time
.map((timeValue, index) => ({
x: Number(timeValue),
y: index,
}))
.filter((sample) => Number.isFinite(sample.x)),
() => chartCursorSamples(time),
[time],
);
@@ -3964,17 +3948,18 @@ function StackedCurvePlot({
);
}
const allSamples = plottedSeries.flatMap((curve) => curve.samples);
const fullXMin = Math.min(...allSamples.map((sample) => sample.x));
const fullXMax = Math.max(...allSamples.map((sample) => sample.x));
const bounds = combinedChartBounds(plottedSeries);
const fullXMin = bounds.xMin;
const fullXMax = bounds.xMax;
const [xMin, xMax] = chartDomainForViewport(fullXMin, fullXMax, viewport.x);
const xRange = Math.max(xMax - xMin, 1e-12);
const xPosition = (value: number) =>
margin.left + ((value - xMin) / xRange) * plotWidth;
const xTicks = chartTicks(xMin, xMax, 5);
const plotBands = plottedSeries.map((curve, index) => {
const rawMinimum = Math.min(...curve.samples.map((sample) => sample.y));
const rawMaximum = Math.max(...curve.samples.map((sample) => sample.y));
const curveBounds = chartSampleBounds(curve.samples);
const rawMinimum = curveBounds.yMin;
const rawMaximum = curveBounds.yMax;
const padding =
rawMinimum === rawMaximum
? Math.max(Math.abs(rawMinimum) * 0.05, 1)
@@ -4023,7 +4008,7 @@ function StackedCurvePlot({
index,
label: curve.label,
unit: curve.unit,
y: Number(curve.values[Math.round(cursorSample.y)]),
y: Number((curve.cursorValues ?? curve.values)[Math.round(cursorSample.y)]),
}))
.filter((reading) => Number.isFinite(reading.y))
: [];
@@ -4200,11 +4185,12 @@ function StackedCurvePlot({
</defs>
{plotBands.map((band) => {
const isLast = band.index === plotBands.length - 1;
const path = segmentedChartPath(
const path = sampledChartPath(
time,
band.curve.values,
xPosition,
band.yPosition,
xMin, xMax, plotWidth,
);
return (
<g className="result-chart-stack-band" key={band.curve.key}>
@@ -4477,6 +4463,7 @@ function StackedCurvePlot({
type CurvePlotProps = {
time: number[];
values: number[];
cursorValues?: number[];
title: string;
unit: string;
color: string;
@@ -4507,6 +4494,7 @@ type CursorLineDragState = {
function CurvePlot({
time,
values,
cursorValues = values,
title,
unit,
color,
@@ -4526,17 +4514,13 @@ function CurvePlot({
const [cursorPanelPosition, setCursorPanelPosition] =
useState<CursorPanelPosition>({ x: 238, y: 9 });
const samples = useMemo<CurveSample[]>(
() =>
time
.map((timeValue, index) => ({
x: Number(timeValue),
y: Number(values[index]),
}))
.filter(
(sample) => Number.isFinite(sample.x) && Number.isFinite(sample.y),
),
() => preparedChartSamples(time, values),
[time, values],
);
const cursorSamples = useMemo(
() => preparedChartSamples(time, cursorValues),
[time, cursorValues],
);
useEffect(() => {
const body = bodyRef.current;
@@ -4565,15 +4549,15 @@ function CurvePlot({
}, []);
useEffect(() => {
if (!cursorEnabled || samples.length === 0) {
if (!cursorEnabled || cursorSamples.length === 0) {
return;
}
setCursorIndex((current) =>
current !== null && current < samples.length
current !== null && current < cursorSamples.length
? current
: Math.floor(samples.length / 2),
: Math.floor(cursorSamples.length / 2),
);
}, [cursorEnabled, samples.length]);
}, [cursorEnabled, cursorSamples.length]);
useEffect(() => {
const body = bodyRef.current;
@@ -4635,11 +4619,12 @@ function CurvePlot({
);
}
const fullXMin = Math.min(...samples.map((sample) => sample.x));
const fullXMax = Math.max(...samples.map((sample) => sample.x));
const bounds = chartSampleBounds(samples);
const fullXMin = bounds.xMin;
const fullXMax = bounds.xMax;
const [xMin, xMax] = chartDomainForViewport(fullXMin, fullXMax, viewport.x);
const rawYMin = Math.min(...samples.map((sample) => sample.y));
const rawYMax = Math.max(...samples.map((sample) => sample.y));
const rawYMin = bounds.yMin;
const rawYMax = bounds.yMax;
const yPadding =
rawYMin === rawYMax
? Math.max(Math.abs(rawYMin) * 0.05, 1)
@@ -4657,23 +4642,24 @@ function CurvePlot({
margin.top + (1 - (value - yMin) / yRange) * plotHeight;
const xTicks = chartTicks(xMin, xMax, 5);
const yTicks = chartTicks(yMin, yMax, 5);
const path = segmentedChartPath(
const path = sampledChartPath(
time,
values,
xPosition,
yPosition,
xMin, xMax, plotWidth,
);
const requestedCursorIndex =
cursorIndex === null
? Math.floor(samples.length / 2)
: Math.min(cursorIndex, samples.length - 1);
? Math.floor(cursorSamples.length / 2)
: Math.min(cursorIndex, cursorSamples.length - 1);
const activeCursorIndex = clampCursorIndexToVisibleX(
samples,
cursorSamples,
requestedCursorIndex,
xMin,
xMax,
);
const cursorSample = samples[activeCursorIndex];
const cursorSample = cursorSamples[activeCursorIndex];
const updateCursorByX = (clientX: number, svg: SVGSVGElement) => {
const bounds = svg.getBoundingClientRect();
@@ -4686,7 +4672,7 @@ function CurvePlot({
Math.max(margin.left, svgX),
);
const targetX = xMin + ((clampedX - margin.left) / plotWidth) * xRange;
setCursorIndex(nearestSampleIndex(samples, targetX));
setCursorIndex(nearestSampleIndex(cursorSamples, targetX));
};
const updateCursorByY = (clientY: number, svg: SVGSVGElement) => {
@@ -4701,7 +4687,7 @@ function CurvePlot({
);
const targetY = yMax - ((clampedY - margin.top) / plotHeight) * yRange;
setCursorIndex((current) =>
nearestSampleIndexByY(samples, targetY, current ?? activeCursorIndex),
nearestSampleIndexByY(cursorSamples, targetY, current ?? activeCursorIndex),
);
};
+119
View File
@@ -0,0 +1,119 @@
export type AlignmentPoint = { x: number; y: number };
export type AlignmentKind = "center" | "origin" | "port";
export type AlignmentAnchor = AlignmentPoint & {
nodeId: string;
id: string;
kind: AlignmentKind;
};
export type AlignmentGuide = {
axis: "x" | "y";
kind: AlignmentKind;
from: AlignmentPoint;
to: AlignmentPoint;
};
export type AlignmentMatch = {
positionDelta: AlignmentPoint;
guides: AlignmentGuide[];
};
type CoordinateGroup = { coordinate: number; anchors: AlignmentAnchor[] };
export type AlignmentIndex = Record<"x" | "y", Record<AlignmentKind, CoordinateGroup[]>>;
const KINDS: AlignmentKind[] = ["port", "center", "origin"];
function lowerBound<T>(items: T[], value: number, coordinate: (item: T) => number) {
let low = 0;
let high = items.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (coordinate(items[middle]) < value) low = middle + 1;
else high = middle;
}
return low;
}
/** Build once per drag. Equal X/Y values are grouped, then sorted by the other axis. */
export function buildAlignmentIndex(anchors: AlignmentAnchor[]): AlignmentIndex {
const index: AlignmentIndex = {
x: { center: [], origin: [], port: [] },
y: { center: [], origin: [], port: [] },
};
for (const axis of ["x", "y"] as const) {
const other = axis === "x" ? "y" : "x";
for (const kind of KINDS) {
const groups = new Map<number, AlignmentAnchor[]>();
for (const anchor of anchors) {
if (anchor.kind !== kind || !Number.isFinite(anchor.x) || !Number.isFinite(anchor.y)) continue;
const group = groups.get(anchor[axis]);
if (group) group.push(anchor);
else groups.set(anchor[axis], [anchor]);
}
index[axis][kind] = [...groups].map(([coordinate, items]) => ({
coordinate,
anchors: items.sort((a, b) => a[other] - b[other] || a.id.localeCompare(b.id)),
})).sort((a, b) => a.coordinate - b.coordinate);
}
}
return index;
}
/** Same-kind anchors align independently on X/Y; the whole moving block gets one delta. */
export function findAlignment(
moving: AlignmentAnchor[],
index: AlignmentIndex,
zoom: number,
): AlignmentMatch | null {
// 排版吸附采用屏幕容差,与可建立连接的接触吸附半径相互独立。
const scale = Math.max(zoom, 0.1);
const tolerance = Math.min(8 / scale, 24);
const maximumSpan = 900 / scale;
const matches: Array<{ axis: "x" | "y"; source: AlignmentAnchor; target: AlignmentAnchor }> = [];
for (const axis of ["x", "y"] as const) {
const other = axis === "x" ? "y" : "x";
let best: { source: AlignmentAnchor; target: AlignmentAnchor; distance: number; span: number } | null = null;
for (const source of moving) {
const groups = index[axis][source.kind];
for (let i = lowerBound(groups, source[axis] - tolerance, (g) => g.coordinate);
i < groups.length && groups[i].coordinate <= source[axis] + tolerance; i++) {
const group = groups[i];
const insertion = lowerBound(group.anchors, source[other], (a) => a[other]);
// 即使成千元件共用同一条中心线,也只检查另一坐标上最近的两个候选。
for (const j of [insertion - 1, insertion]) {
const target = group.anchors[j];
if (!target || target.nodeId === source.nodeId) continue;
const distance = Math.abs(target[axis] - source[axis]);
const span = Math.abs(target[other] - source[other]);
if (span > maximumSpan) continue;
if (!best || distance < best.distance - 0.01 ||
(Math.abs(distance - best.distance) <= 0.01 &&
(KINDS.indexOf(source.kind) < KINDS.indexOf(best.source.kind) ||
(source.kind === best.source.kind && span < best.span)))) {
best = { source, target, distance, span };
}
}
}
}
if (best) matches.push({ axis, source: best.source, target: best.target });
}
if (matches.length === 0) return null;
const positionDelta = { x: 0, y: 0 };
for (const match of matches) {
positionDelta[match.axis] = match.target[match.axis] - match.source[match.axis];
}
return {
positionDelta,
guides: matches.map(({ axis, source, target }) => ({
axis, kind: source.kind,
from: { x: source.x + positionDelta.x, y: source.y + positionDelta.y },
to: { x: target.x, y: target.y },
})),
};
}
export function sameAlignmentGuides(a: AlignmentGuide[], b: AlignmentGuide[]) {
return a.length === b.length && a.every((guide, i) => {
const other = b[i];
return guide.axis === other.axis && guide.kind === other.kind &&
guide.from.x === other.from.x && guide.from.y === other.from.y &&
guide.to.x === other.to.x && guide.to.y === other.to.y;
});
}
+139
View File
@@ -0,0 +1,139 @@
export type ChartSample = { x: number; y: number; dataIndex: number; breakBefore: boolean };
type Bounds = { xMin: number; xMax: number; yMin: number; yMax: number; sorted: boolean };
const seriesCache = new WeakMap<number[], WeakMap<number[], ChartSample[]>>();
const boundsCache = new WeakMap<Array<{ x: number; y: number }>, Bounds>();
const cursorCache = new WeakMap<number[], ChartSample[]>();
const selectionCache = new WeakMap<ChartSample[], Map<string, ChartSample[]>>();
/** Cached only by immutable source arrays; collected together with their result snapshot. */
export function preparedChartSamples(time: number[], values: number[]) {
let byTime = seriesCache.get(values);
if (!byTime) { byTime = new WeakMap(); seriesCache.set(values, byTime); }
let samples = byTime.get(time);
if (samples) return samples;
samples = [];
let breakBefore = true;
for (let i = 0; i < time.length; i++) {
const x = Number(time[i]), y = Number(values[i]);
if (!Number.isFinite(x)) { breakBefore = true; continue; }
// Preserve the existing isolated-force-event interpolation policy.
if (!Number.isFinite(y)) continue;
samples.push({ x, y, dataIndex: i, breakBefore });
breakBefore = false;
}
byTime.set(time, samples);
return samples;
}
export function chartCursorSamples(time: number[]) {
let samples = cursorCache.get(time);
if (!samples) {
samples = [];
for (let i = 0; i < time.length; i++) {
if (Number.isFinite(time[i])) samples.push({ x: time[i], y: i, dataIndex: i, breakBefore: false });
}
cursorCache.set(time, samples);
}
return samples;
}
export function chartSampleBounds(samples: Array<{ x: number; y: number }>): Bounds {
const cached = boundsCache.get(samples);
if (cached) return cached;
const bounds = { xMin: Infinity, xMax: -Infinity, yMin: Infinity, yMax: -Infinity, sorted: true };
let previousX = -Infinity;
for (const sample of samples) {
bounds.xMin = Math.min(bounds.xMin, sample.x);
bounds.xMax = Math.max(bounds.xMax, sample.x);
bounds.yMin = Math.min(bounds.yMin, sample.y);
bounds.yMax = Math.max(bounds.yMax, sample.y);
if (sample.x < previousX) bounds.sorted = false;
previousX = sample.x;
}
boundsCache.set(samples, bounds);
return bounds;
}
export function combinedChartBounds(curves: Array<{ samples: Array<{ x: number; y: number }> }>) {
const result = { xMin: Infinity, xMax: -Infinity, yMin: Infinity, yMax: -Infinity };
for (const curve of curves) {
const bounds = chartSampleBounds(curve.samples);
result.xMin = Math.min(result.xMin, bounds.xMin);
result.xMax = Math.max(result.xMax, bounds.xMax);
result.yMin = Math.min(result.yMin, bounds.yMin);
result.yMax = Math.max(result.yMax, bounds.yMax);
}
return result;
}
function lowerBound(samples: Array<{ x: number }>, x: number, upper = false) {
let low = 0, high = samples.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (samples[middle].x < x || (upper && samples[middle].x === x)) low = middle + 1;
else high = middle;
}
return low;
}
export function visibleSampleRange(samples: Array<{ x: number; y: number }>, xMin: number, xMax: number) {
if (!chartSampleBounds(samples).sorted) return [0, samples.length] as const;
// Include both outside neighbours, preserving segments crossing viewport boundaries.
return [Math.max(0, lowerBound(samples, xMin) - 1), Math.min(samples.length, lowerBound(samples, xMax, true) + 1)] as const;
}
/** Keep first/min/max/last per pixel column in original order; no averaging of peaks. */
export function chartDisplaySamples(samples: ChartSample[], xMin: number, xMax: number, pixelWidth: number) {
const width = Math.max(1, Math.ceil(pixelWidth));
const key = `${xMin}:${xMax}:${width}`;
let cache = selectionCache.get(samples);
if (!cache) { cache = new Map(); selectionCache.set(samples, cache); }
const cached = cache.get(key);
if (cached) return cached;
const [start, end] = visibleSampleRange(samples, xMin, xMax);
const result: ChartSample[] = [];
if (end - start <= width * 4 || !chartSampleBounds(samples).sorted) {
for (let i = start; i < end; i++) result.push(samples[i]);
} else {
let bucket = -Infinity;
let first = -1, last = -1, minimum = -1, maximum = -1;
const flush = () => {
if (first < 0) return;
const indices = [first, minimum, maximum, last].sort((a, b) => a - b);
let previous = -1;
for (const index of indices) {
if (index !== previous) result.push(samples[index]);
previous = index;
}
};
for (let i = start; i < end; i++) {
const sample = samples[i];
const nextBucket = Math.max(-1, Math.min(width, Math.floor((sample.x - xMin) / Math.max(xMax - xMin, 1e-12) * width)));
if (nextBucket !== bucket || sample.breakBefore || first < 0) {
flush();
first = last = minimum = maximum = i;
bucket = nextBucket;
} else {
last = i;
if (sample.y < samples[minimum].y) minimum = i;
if (sample.y > samples[maximum].y) maximum = i;
}
}
flush();
}
// A few active windows/viewports may share a curve without retaining unbounded history.
if (cache.size >= 4) cache.delete(cache.keys().next().value!);
cache.set(key, result);
return result;
}
export function sampledChartPath(
time: number[], values: number[],
xPosition: (value: number) => number, yPosition: (value: number) => number,
xMin: number, xMax: number, pixelWidth: number,
) {
const samples = chartDisplaySamples(preparedChartSamples(time, values), xMin, xMax, pixelWidth);
return samples.map((sample, index) =>
`${index === 0 || sample.breakBefore ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${yPosition(sample.y).toFixed(2)}`,
).join(" ");
}
+56 -1
View File
@@ -74,7 +74,11 @@ export function simplifyOrthogonalPoints(points: EdgeRoutePoint[]) {
const collinearY =
Math.abs(previous.y - current.y) <= ROUTE_EPSILON &&
Math.abs(current.y - next.y) <= ROUTE_EPSILON;
if (!collinearX && !collinearY) {
const continuesForward =
(collinearX && (current.y - previous.y) * (next.y - current.y) > 0) ||
(collinearY && (current.x - previous.x) * (next.x - current.x) > 0);
// 共线但折返的点仍是有效拐点,删除它会抹掉已确认的那一段线路。
if (!continuesForward) {
simplified.push(current);
}
}
@@ -161,6 +165,57 @@ export function buildOrthogonalRoutePoints(
return simplifyOrthogonalPoints(points).slice(1, -1);
}
function connectionDraftAxis(
fixedPoints: EdgeRoutePoint[],
sourcePosition: Position,
): EdgePointAxis {
const previous = fixedPoints.at(-2);
const anchor = fixedPoints.at(-1);
// 每次确认的是一个转角,下一段与刚确认的线段垂直。
return previous && anchor
? edgeSegmentAxis(previous, anchor) === "horizontal" ? "vertical" : "horizontal"
: edgeAxisForPosition(sourcePosition);
}
/** One click fixes one segment and its corner, not the whole rubber-band tail. */
export function confirmOrthogonalCorner(
fixedPoints: EdgeRoutePoint[],
pointer: EdgeRoutePoint,
sourcePosition: Position,
) {
const anchor = fixedPoints.at(-1);
if (!anchor || !edgePointIsFinite(pointer)) return fixedPoints;
const corner = connectionDraftAxis(fixedPoints, sourcePosition) === "horizontal"
? { x: pointer.x, y: anchor.y }
: { x: anchor.x, y: pointer.y };
// 原地点击不生成零长度线段,也不改变下一段方向。
if (edgePointsMatch(anchor, corner)) return fixedPoints;
return [...fixedPoints, corner];
}
/** Extend only the live L-shaped tail; previously confirmed corners stay fixed. */
export function extendOrthogonalRoute(
fixedPoints: EdgeRoutePoint[],
target: EdgeRoutePoint,
sourcePosition: Position,
targetPosition?: Position,
) {
const points = fixedPoints.map((point) => ({ ...point }));
const anchor = points.at(-1);
if (!anchor || edgePointsMatch(anchor, target)) {
return points;
}
const nextAxis = connectionDraftAxis(points, sourcePosition);
// 只有靠近真实目标端口时才按其朝向收尾;空白处不猜测端口方向或插入中点。
if (targetPosition !== undefined) {
appendEndpointAwareLeg(points, target, nextAxis, edgeAxisForPosition(targetPosition));
} else {
appendOrthogonalLeg(points, target, nextAxis);
}
// 不对 fixedPoints 重新路由或跨边界简化,否则已确认的前缀会随鼠标变化。
return points;
}
/** Returns the rendered polyline, including both live port endpoints. */
export function orthogonalEdgePoints({
routePoints,
+31
View File
@@ -0,0 +1,31 @@
/** Decode NDJSON without rescanning or flattening an ever-growing result line. */
export function createNdjsonLineDecoder(onLine: (line: string) => void) {
const decoder = new TextDecoder();
let fragments: string[] = [];
const consumeText = (text: string) => {
let start = 0;
for (let end = text.indexOf("\n"); end >= 0; end = text.indexOf("\n", start)) {
if (end > start) fragments.push(text.slice(start, end));
const line = fragments.length === 1 ? fragments[0] : fragments.join("");
fragments = [];
onLine(line);
start = end + 1;
}
if (start < text.length) fragments.push(text.slice(start));
};
return {
write(chunk: Uint8Array) {
consumeText(decoder.decode(chunk, { stream: true }));
},
finish() {
consumeText(decoder.decode());
if (fragments.length > 0) {
const line = fragments.length === 1 ? fragments[0] : fragments.join("");
fragments = [];
onLine(line);
}
},
};
}
+147
View File
@@ -0,0 +1,147 @@
// Supply direction is independent of gas flow direction and canvas orientation.
export type PortComputation = {
mode: "equation" | "fixed";
inputs: string[];
outputs: string[];
referencePort?: string;
};
type ComputationPort = {
name: string;
kind: string;
domain: string;
nominalRole: string;
computation?: PortComputation;
};
const labels: Record<string, string> = {
p: "压力", T: "温度", m_flow: "质量流率", H_flow: "能量流率",
};
const quantities = (names: string[]) => names.map((name) => labels[name] ?? name).join("、");
export function normalizePortComputation(value: unknown): PortComputation | undefined {
if (value === undefined) return undefined;
if (!value || typeof value !== "object") throw new Error("端口供需合同格式无效");
const item = value as Partial<PortComputation>;
const validNames = (names: unknown): names is string[] => Array.isArray(names) &&
names.every((name) => typeof name === "string" && Object.hasOwn(labels, name));
if ((item.mode !== "equation" && item.mode !== "fixed") ||
!validNames(item.inputs) || !validNames(item.outputs) ||
new Set([...item.inputs, ...item.outputs]).size !== item.inputs.length + item.outputs.length ||
(item.referencePort !== undefined && (typeof item.referencePort !== "string" ||
!item.referencePort || !["p", "T"].every((v) => item.outputs!.includes(v))))) {
throw new Error("端口供需合同包含无效变量或参考口");
}
return { mode: item.mode, inputs: [...item.inputs], outputs: [...item.outputs],
...(item.referencePort ? { referencePort: item.referencePort } : {}) };
}
export function portTypeIssue(first: ComputationPort, second: ComputationPort) {
if (first.kind !== second.kind || first.domain !== second.domain) {
return "端口类型或物理域不兼容";
}
if (first.kind === "signal" && !(
new Set([first.nominalRole, second.nominalRole]).size === 2 &&
[first.nominalRole, second.nominalRole].every((role) => role === "input" || role === "output")
)) {
return "信号连接需要一个输出口和一个输入口";
}
return null;
}
export function portSupplyIssue(
first: ComputationPort, second: ComputationPort,
firstLabel = first.name, secondLabel = second.name,
) {
if (first.kind !== second.kind || first.domain !== second.domain) return null;
const a = first.computation, b = second.computation;
if (a?.mode !== "fixed" && b?.mode !== "fixed") return null;
for (const [consumer, supplier, consumerLabel, supplierLabel] of [
[a, b, firstLabel, secondLabel], [b, a, secondLabel, firstLabel],
] as const) {
if (!consumer) continue;
const missing = consumer.inputs.filter((name) => !supplier?.outputs.includes(name));
if (missing.length) {
return `${consumerLabel} 需要对端提供${quantities(missing)},但 ${supplierLabel} 未提供;请检查参考口与支路口的连接。气体流向反转不会改变这一供需关系。`;
}
}
return null;
}
export function portConnectionIssue(
first: ComputationPort, second: ComputationPort,
firstLabel = first.name, secondLabel = second.name,
) {
return portTypeIssue(first, second) ?? portSupplyIssue(first, second, firstLabel, secondLabel);
}
export function portComputationDescription(port: ComputationPort) {
const lines = [`${port.name} (${port.domain})`];
const contract = port.computation;
if (contract) {
if (contract.inputs.length) lines.push(`需要:${quantities(contract.inputs)}`);
if (contract.outputs.length) lines.push(`提供:${quantities(contract.outputs)}`);
if (contract.referencePort) lines.push(`温度、压力来自 ${contract.referencePort}`);
if (contract.mode === "fixed" && contract.inputs.includes("T")) lines.push("温度、压力参考口");
if (!contract.inputs.length && !contract.outputs.length) lines.push("变量由连接方程共同确定");
}
return lines.join("\n");
}
type SupplyNode = { id: string; data: { ports: ComputationPort[] } };
type SupplyEdge = { source: string; sourceHandle?: string | null; target: string; targetHandle?: string | null };
export function referenceSupplyIssues(nodes: SupplyNode[], edges: SupplyEdge[]) {
const key = (node: string, port: string) => JSON.stringify([node, port]);
const ports = new Map(nodes.flatMap((node) => node.data.ports.map((port) =>
[key(node.id, port.name), { node: node.id, port }] as const)));
const adjacency = new Map<string, string>();
for (const edge of edges) {
const a = key(edge.source, edge.sourceHandle ?? "");
const b = key(edge.target, edge.targetHandle ?? "");
if (ports.get(a)?.port.kind === "physical" && ports.get(b)?.port.kind === "physical") {
adjacency.set(a, b); adjacency.set(b, a);
}
}
const issues: Array<{ nodeId: string; message: string }> = [];
const resolved = new Map(["p", "T"].map((v) => [v, new Set<string>()]));
for (const [endpoint, entry] of ports) {
const contract = entry.port.computation;
if (contract?.mode !== "fixed" || !["p", "T"].every((v) => contract.inputs.includes(v)) || !adjacency.has(endpoint)) continue;
for (const variable of ["p", "T"]) {
let current = endpoint;
const visited = new Set<string>();
const chain: string[] = [];
const start = `${entry.node}.${entry.port.name}`;
while (true) {
if (resolved.get(variable)!.has(current)) {
visited.forEach((item) => resolved.get(variable)!.add(item));
break;
}
const input = ports.get(current);
if (!input) break;
const label = `${input.node}.${input.port.name}`;
if (visited.has(current)) {
issues.push({ nodeId: entry.node, message: `${start} 的${labels[variable]}参考形成循环,没有实际提供者:${chain.join(" → ")} → ${label}。` });
break;
}
visited.add(current); chain.push(label);
const supplierKey = adjacency.get(current);
if (!supplierKey) {
issues.push({ nodeId: entry.node, message: `${start} 的${labels[variable]}参考链在 ${label} 中断:该参考输入尚未连接。` });
break;
}
const supplier = ports.get(supplierKey);
const supply = supplier?.port.computation;
if (!supply?.outputs.includes(variable) || !supplier) break;
if (!supply.referencePort) {
visited.forEach((item) => resolved.get(variable)!.add(item));
break;
}
chain.push(`${supplier.node}.${supplier.port.name}`);
current = key(supplier.node, supply.referencePort);
}
}
}
return issues;
}
+165
View File
@@ -0,0 +1,165 @@
import type { SimulationResultsSnapshot } from "./SimulationResultsView";
export const RESULT_SNAPSHOT_KEY = "system-simulation-flow:latest-result";
const DATABASE = "system-simulation-results";
const CHUNK_SIZE = 32768;
const BATCH_VALUES = 131072;
type Header = {
snapshot: SimulationResultsSnapshot;
lengths: Record<string, number>;
};
type Chunk = { key: IDBValidKey; values: Float64Array };
let database: Promise<IDBDatabase> | undefined;
let saveSequence = 0;
let pendingSaves = 0;
let ownedCacheId: string | undefined;
export function resultSavePending() { return pendingSaves > 0; }
function openDatabase() {
if (!database) database = new Promise<IDBDatabase>((resolve, reject) => {
let blocked = false;
const request = indexedDB.open(DATABASE, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore("headers");
request.result.createObjectStore("chunks");
};
request.onerror = () => reject(request.error);
request.onblocked = () => {
blocked = true;
reject(new Error("结果数据库被其他页面占用,请关闭旧页面后重试"));
};
request.onsuccess = () => {
const db = request.result;
if (blocked) { db.close(); return; }
db.onversionchange = () => { db.close(); database = undefined; };
resolve(db);
};
}).catch((error) => { database = undefined; throw error; });
return database;
}
function writeBatch(db: IDBDatabase, chunks: Chunk[], header?: [string, Header]) {
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction(["headers", "chunks"], "readwrite");
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error ?? new Error("结果保存事务已中止"));
transaction.onerror = () => {}; // onabort reports the transaction failure once.
try {
for (const chunk of chunks) transaction.objectStore("chunks").put(chunk.values, chunk.key);
if (header) transaction.objectStore("headers").put(header[1], header[0]);
} catch (error) {
transaction.abort();
reject(error);
}
});
}
function deleteCache(db: IDBDatabase, cacheId: string) {
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction(["headers", "chunks"], "readwrite");
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error);
transaction.objectStore("headers").delete(cacheId);
transaction.objectStore("chunks").delete(IDBKeyRange.bound([cacheId], [cacheId, []]));
});
}
/** Publish the small session pointer only after every data chunk is durably committed. */
export async function storeResultSnapshot(snapshot: SimulationResultsSnapshot) {
const sequence = ++saveSequence;
const cacheId = typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${sequence}-${Math.random().toString(36).slice(2)}`;
pendingSaves++;
let db: IDBDatabase | undefined;
let committed = false;
try {
// Yield before serialization so the ready result and controls can paint first.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
if (sequence !== saveSequence) return false;
db = await openDatabase();
const lengths: Record<string, number> = Object.create(null);
let batch: Chunk[] = [];
let batchSize = 0;
for (const [name, values] of Object.entries(snapshot.result.series)) {
lengths[name] = values.length;
for (let offset = 0; offset < values.length; offset += CHUNK_SIZE) {
if (sequence !== saveSequence) return false;
const length = Math.min(CHUNK_SIZE, values.length - offset);
const block = new Float64Array(length);
for (let i = 0; i < length; i++) block[i] = values[offset + i];
batch.push({ key: [cacheId, name, offset], values: block });
batchSize += length;
if (batchSize >= BATCH_VALUES) {
await writeBatch(db, batch);
batch = [];
batchSize = 0;
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
}
}
if (sequence !== saveSequence) return false;
await writeBatch(db, batch, [cacheId, {
snapshot: { ...snapshot, result: { ...snapshot.result, series: Object.create(null) } }, lengths,
}]);
if (sequence !== saveSequence) return false;
sessionStorage.setItem(RESULT_SNAPSHOT_KEY, JSON.stringify({ storage: "indexeddb", version: 1, cacheId }));
committed = true;
// Only retire this page's own previous save, never another tab's loaded snapshot.
const previous = ownedCacheId;
ownedCacheId = cacheId;
if (previous) void deleteCache(db, previous).catch(() => {});
return true;
} finally {
pendingSaves--;
if (db && !committed) await deleteCache(db, cacheId).catch(() => {});
}
}
export async function loadStoredResultSnapshot(): Promise<unknown | null> {
const raw = sessionStorage.getItem(RESULT_SNAPSHOT_KEY);
if (!raw) return null;
const value = JSON.parse(raw);
// Read legacy sessionStorage data without rewriting or duplicating it at startup.
if (value?.storage !== "indexeddb") return value;
if (value.version !== 1 || typeof value.cacheId !== "string") return null;
const db = await openDatabase();
return new Promise<SimulationResultsSnapshot | null>((resolve, reject) => {
const transaction = db.transaction(["headers", "chunks"], "readonly");
let result: SimulationResultsSnapshot | null = null;
let failure: Error | null = null;
transaction.oncomplete = () => failure ? reject(failure) : resolve(result);
transaction.onabort = () => reject(transaction.error);
const request = transaction.objectStore("headers").get(value.cacheId);
request.onsuccess = () => {
const header = request.result as Header | undefined;
if (!header) { failure = new Error("结果缓存已丢失,请重新载入结果文件"); return; }
result = header.snapshot;
for (const [name, length] of Object.entries(header.lengths)) {
result.result.series[name] = new Array<number>(length);
}
const loaded: Record<string, number> = Object.create(null);
const cursor = transaction.objectStore("chunks").openCursor(IDBKeyRange.bound([value.cacheId], [value.cacheId, []]));
cursor.onsuccess = () => {
const entry = cursor.result;
if (!entry) {
for (const [name, length] of Object.entries(header.lengths)) {
if ((loaded[name] ?? 0) !== length) failure = new Error("结果缓存不完整,请重新载入结果文件");
}
return;
}
const [, name, offset] = entry.key as [string, string, number];
const block = entry.value as Float64Array;
const values = result!.result.series[name];
if (!values || offset < 0 || offset + block.length > values.length) {
failure = new Error("结果缓存分块无效");
} else {
for (let i = 0; i < block.length; i++) values[offset + i] = block[i];
loaded[name] = (loaded[name] ?? 0) + block.length;
}
entry.continue();
};
};
});
}
+96
View File
@@ -0,0 +1,96 @@
export type SimulationConsoleLevel = "info" | "success" | "warning" | "error";
export type SimulationConsoleEntry = {
id: number;
timestamp: string;
level: SimulationConsoleLevel;
message: string;
format: "message" | "xml";
};
export type SimulationProgressState = {
state: "idle" | "running" | "success" | "warning" | "error";
percent: number;
message: string;
startTime: number;
simulatedTime: number;
totalTime: number;
};
export const SIMULATION_PROGRESS_REFRESH_MS = 250;
type ProgressUpdate =
| SimulationProgressState
| ((current: SimulationProgressState) => SimulationProgressState);
function sameProgress(a: SimulationProgressState, b: SimulationProgressState) {
return (
a.state === b.state &&
a.percent === b.percent &&
a.message === b.message &&
a.startTime === b.startTime &&
a.simulatedTime === b.simulatedTime &&
a.totalTime === b.totalTime
);
}
/** 仅控制台订阅,进度/日志变化不触发建模工作区渲染。 */
export function createSimulationConsoleStore(initialProgress: SimulationProgressState) {
let snapshot = { entries: [] as SimulationConsoleEntry[], progress: initialProgress };
let latestProgress = initialProgress;
let timer: ReturnType<typeof setTimeout> | undefined;
const listeners = new Set<() => void>();
const notify = () => listeners.forEach((listener) => listener());
const cancelPendingProgress = () => {
if (timer !== undefined) clearTimeout(timer);
timer = undefined;
};
const publishProgress = () => {
timer = undefined;
if (sameProgress(snapshot.progress, latestProgress)) return;
snapshot = { ...snapshot, progress: latestProgress };
notify();
};
const setProgress = (update: ProgressUpdate) => {
cancelPendingProgress();
latestProgress =
typeof update === "function" ? update(latestProgress) : update;
publishProgress();
};
return {
getSnapshot: () => snapshot,
getProgress: () => latestProgress,
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
setProgress,
queueProgress(update: ProgressUpdate, immediate = false) {
const next = typeof update === "function" ? update(latestProgress) : update;
if (immediate || next.state !== "running") {
setProgress(next);
return;
}
latestProgress = next;
if (timer === undefined && !sameProgress(snapshot.progress, latestProgress)) {
timer = setTimeout(publishProgress, SIMULATION_PROGRESS_REFRESH_MS);
}
},
// 日志保留既有 400 条上限;关键动作立即显示,不随进度一起丢弃。
appendEntry(entry: SimulationConsoleEntry) {
snapshot = { ...snapshot, entries: [...snapshot.entries.slice(-399), entry] };
notify();
},
clearEntries() {
if (snapshot.entries.length === 0) return;
snapshot = { ...snapshot, entries: [] };
notify();
},
cancelPendingProgress,
};
}
export type SimulationConsoleStore = ReturnType<typeof createSimulationConsoleStore>;
+38
View File
@@ -3787,3 +3787,41 @@ textarea {
.flow-canvas.connection-planning .react-flow__pane {
cursor: crosshair;
}
.alignment-guides {
position: absolute;
top: 0;
left: 0;
overflow: visible;
pointer-events: none;
/* 整层半透明合成,横竖辅助线及定位点重叠时不会叠成深色。 */
opacity: 0.5;
z-index: 8;
}
.alignment-guides line {
stroke: #80c5ef;
stroke-width: 4;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.alignment-guides circle {
fill: #80c5ef;
stroke: none;
}
/* 连接模式下图形与编辑手柄不拦截落点,点击直接交给画布添加折点。
* 使用模式级覆盖统一关闭各图标自行定义的命中区,仅起点和兼容接口可交互。
*/
.flow-canvas.connection-active .react-flow__node,
.flow-canvas.connection-active .react-flow__node *,
.flow-canvas.connection-active .react-flow__edge,
.flow-canvas.connection-active .react-flow__edge * {
pointer-events: none !important;
}
.flow-canvas.connection-active .react-flow__node .port-handle.port-connection-origin,
.flow-canvas.connection-active .react-flow__node .port-handle.port-connection-compatible {
pointer-events: auto !important;
}
+203
View File
@@ -0,0 +1,203 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { buildAlignmentIndex, findAlignment, type AlignmentAnchor } from "../../src/alignmentGuides";
import { componentCatalog, expectAllNodesInsideCanvas, prepareApp, wideProject } from "./fixtures";
const KEY = "system-simulation-flow:project:demo-system";
const anchor = (nodeId: string, kind: AlignmentAnchor["kind"], x: number, y: number): AlignmentAnchor =>
({ id: `${nodeId}:${kind}`, nodeId, kind, x, y });
test("中心、定位点和端口按同类参考点对齐,整块采用同一个平移量", () => {
for (const kind of ["center", "origin", "port"] as const) {
const index = buildAlignmentIndex([anchor("ref-x", kind, 100, 200), anchor("ref-y", kind, 300, 40)]);
const result = findAlignment([anchor("a", kind, 95, 10), anchor("b", kind, 145, 37)], index, 1)!;
expect(result.positionDelta).toEqual({ x: 5, y: 3 });
expect(result.guides.map((g) => g.kind)).toEqual([kind, kind]);
expect(result.guides.every((g) => g.from[g.axis] === g.to[g.axis])).toBe(true);
}
expect(findAlignment([anchor("a", "origin", 95, 10)],
buildAlignmentIndex([anchor("ref", "center", 100, 200)]), 1)).toBeNull();
});
test("对齐容差保持约 8 屏幕像素,远处元件与离开范围的元件不吸附", () => {
const index = buildAlignmentIndex([anchor("ref", "center", 100, 100)]);
expect(findAlignment([anchor("a", "center", 96.5, 0)], index, 2)?.positionDelta.x).toBe(3.5);
expect(findAlignment([anchor("a", "center", 95, 0)], index, 2)).toBeNull();
expect(findAlignment([anchor("a", "center", 96, 5000)], index, 1)).toBeNull();
expect(findAlignment([anchor("a", "center", 70, 0)], index, 0.1)).toBeNull();
});
test("大量元件共用同一坐标时,索引仍选取另一方向最近的参考点", () => {
const index = buildAlignmentIndex(Array.from({ length: 20000 }, (_, i) => anchor(`ref-${i}`, "port", 100, i * 20)));
const result = findAlignment([anchor("a", "port", 104, 200005)], index, 1)!;
expect(result.positionDelta).toEqual({ x: -4, y: -5 });
expect(result.guides[0].to).toEqual({ x: 100, y: 200000 });
});
async function loadProject(page: Page, ports: boolean, rotatedTarget = false, block = false) {
await prepareApp(page);
if (!ports) {
const catalog = structuredClone(componentCatalog);
catalog.libraries.flatMap((library) => library.components)
.find((component) => component.type === "generic_sensor")!.ports = [];
await page.route("**/api/components/catalog", (route) => route.fulfill({
contentType: "application/json", body: JSON.stringify(catalog),
}));
}
const nodes = [
{ id: "moving", x: 0, y: 0, rotation: 0 },
{ id: "reference", x: 400, y: block ? 400 : 210, rotation: rotatedTarget ? 90 : 0 },
...(block ? [{ id: "partner", x: 0, y: 130, rotation: 0 }] : []),
].map(({ id, x, y, rotation }) => ({
...structuredClone(wideProject.nodes[0]), id, position: { x, y },
data: { ...structuredClone(wideProject.nodes[0].data), label: id, rotation,
ports: ports ? wideProject.nodes[0].data.ports : [] },
}));
await page.addInitScript(({ key, project }) => localStorage.setItem(key, JSON.stringify(project)), {
key: KEY, project: { ...structuredClone(wideProject), nodes, edges: block ? [{
...wideProject.edges[0], source: "moving", target: "partner",
data: { isContactEdge: false, routePoints: [{ x: 200, y: 50 }, { x: 200, y: 170 }] },
}] : [] },
});
await page.goto("/");
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas", block ? 3 : 2);
return {
moving: page.locator('.react-flow__node[data-id="moving"]'),
reference: page.locator('.react-flow__node[data-id="reference"]'),
guides: page.locator(".alignment-guides"),
};
}
async function bounds(locator: Locator) {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
return { ...box!, cx: box!.x + box!.width / 2, cy: box!.y + box!.height / 2 };
}
async function beginDrag(page: Page, node: Locator) {
const before = await bounds(node);
await page.mouse.move(before.cx, before.cy);
await page.mouse.down();
// 先跨过 React Flow 的拖拽启动阈值,再按实际抓取偏移定位。
await page.mouse.move(before.cx, before.cy + 5);
const after = await bounds(node);
return { x: before.cx - after.cx, y: before.cy + 5 - after.cy };
}
for (const kind of ["center", "origin", "port"] as const) {
test(`拖动时显示${kind}辅助线并精确吸附,松手后隐藏,支持撤销`, async ({ page }) => {
const { moving, reference, guides } = await loadProject(page, kind === "port", kind !== "center");
const before = await bounds(moving);
const target = await bounds(reference);
const portSelector = '.port-handle[data-port-name="port_b"]';
const sourceAnchor = kind === "port" ? (await bounds(moving.locator(portSelector))).cy
: kind === "center" ? before.cy : before.y;
const targetAnchor = kind === "port" ? (await bounds(reference.locator(portSelector))).cy
: kind === "center" ? target.cy : target.y;
const grab = await beginDrag(page, moving);
await page.mouse.move(before.cx + grab.x, before.cy + targetAnchor - sourceAnchor + 4 + grab.y, { steps: 8 });
await expect(guides.locator(`[data-alignment-kind="${kind}"]`)).toBeVisible();
await expect(guides.locator("text")).toHaveCount(0);
await expect(guides).toHaveCSS("opacity", "0.5");
await expect(guides).toHaveCSS("pointer-events", "none");
const snapped = await bounds(moving);
const snappedAnchor = kind === "port" ? (await bounds(moving.locator(portSelector))).cy
: kind === "center" ? snapped.cy : snapped.y;
expect(Math.abs(snappedAnchor - targetAnchor)).toBeLessThan(0.7);
const line = guides.locator('[data-alignment-axis="y"] line');
expect(await line.getAttribute("y1")).toBe(await line.getAttribute("y2"));
await expect(line).toHaveCSS("stroke", "rgb(128, 197, 239)");
await expect(line).toHaveCSS("stroke-width", "4px");
await expect(line).toHaveCSS("stroke-dasharray", "none");
await expect(line).toHaveCSS("vector-effect", "non-scaling-stroke");
await expect(guides.locator("circle").first()).toHaveAttribute("r", "5");
await expect(guides.locator("circle").first()).toHaveCSS("fill", "rgb(128, 197, 239)");
await expect(guides.locator("circle").first()).toHaveCSS("stroke", "none");
if (kind === "center") await page.screenshot({ path: "test-results/alignment-preview.png" });
await page.mouse.up();
await expect(guides).toHaveCount(0);
expect(Math.abs((await bounds(moving)).y - snapped.y)).toBeLessThan(0.7);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await page.keyboard.press("Control+z");
expect(Math.abs((await bounds(moving)).y - before.y)).toBeLessThan(0.7);
});
}
test("离开范围、按 Alt 或关闭自动对齐时自由拖动且不残留辅助线", async ({ page }) => {
const { moving, reference, guides } = await loadProject(page, false);
const start = await bounds(moving);
const target = await bounds(reference);
const grab = await beginDrag(page, moving);
await page.mouse.move(start.cx + grab.x, target.cy + 4 + grab.y, { steps: 8 });
await expect(guides).toBeVisible();
await page.keyboard.down("Alt");
await page.mouse.move(start.cx + grab.x, target.cy + 5 + grab.y);
await expect(guides).toHaveCount(0);
expect((await bounds(moving)).cy - target.cy).toBeGreaterThan(3);
await page.keyboard.up("Alt");
await page.mouse.move(start.cx + grab.x, target.cy + 3 + grab.y);
await expect(guides).toBeVisible();
await page.mouse.move(start.cx + grab.x, target.cy + 35 + grab.y);
await expect(guides).toHaveCount(0);
await page.mouse.up();
await page.getByRole("button", { name: "关闭自动对齐(拖动时 Alt 暂停)", exact: true }).click();
const current = await bounds(moving);
const nextGrab = await beginDrag(page, moving);
await page.mouse.move(current.cx + nextGrab.x, target.cy + 4 + nextGrab.y, { steps: 5 });
await expect(guides).toHaveCount(0);
await page.mouse.up();
expect((await bounds(moving)).cy - target.cy).toBeGreaterThan(2);
});
test("多选块对齐保持相对位置,内部折线路径整体平移,保存重载仍一致", async ({ page }) => {
const { moving, reference, guides } = await loadProject(page, true, false, true);
const partner = page.locator('.react-flow__node[data-id="partner"]');
const before = await bounds(moving);
const partnerBefore = await bounds(partner);
const target = await bounds(reference);
await moving.locator(".sim-node").click();
await partner.locator(".sim-node").click({ modifiers: ["Control"] });
const grab = await beginDrag(page, moving);
await page.mouse.move(target.cx + 4 + grab.x, before.cy + grab.y, { steps: 8 });
await expect(guides.locator('[data-alignment-axis="x"]')).toBeVisible();
const moved = await bounds(moving);
const partnerMoved = await bounds(partner);
expect(Math.abs(moved.cx - target.cx)).toBeLessThan(0.7);
expect(Math.abs(partnerMoved.x - moved.x - (partnerBefore.x - before.x))).toBeLessThan(0.7);
expect(Math.abs(partnerMoved.y - moved.y - (partnerBefore.y - before.y))).toBeLessThan(0.7);
await page.mouse.up();
await expect(guides).toHaveCount(0);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const project = await page.evaluate((key) => JSON.parse(localStorage.getItem(key)!), KEY);
const first = project.nodes.find((node: { id: string }) => node.id === "moving");
const second = project.nodes.find((node: { id: string }) => node.id === "partner");
expect(first.position.x).toBeCloseTo(400, 1);
expect(second.position.x).toBeCloseTo(first.position.x, 3);
expect(second.position.y - first.position.y).toBeCloseTo(130, 3);
expect(project.edges[0].data.routePoints).toEqual([
{ x: 200 + first.position.x, y: 50 + first.position.y },
{ x: 200 + first.position.x, y: 170 + first.position.y },
]);
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas", 3);
expect(Math.abs((await bounds(moving)).cx - (await bounds(reference)).cx)).toBeLessThan(0.7);
});
test("接触端口吸附优先,显示端口辅助线,只有落下后才建立连接", async ({ page }) => {
const { moving, reference, guides } = await loadProject(page, true);
const start = await bounds(moving);
const source = await bounds(moving.locator('.port-handle[data-port-name="port_b"]'));
const target = await bounds(reference.locator('.port-handle[data-port-name="port_a"]'));
const grab = await beginDrag(page, moving);
await page.mouse.move(start.cx + target.cx - source.cx - 5 + grab.x,
start.cy + target.cy - source.cy + 4 + grab.y, { steps: 12 });
await expect(guides.locator('[data-alignment-kind="port"]')).toHaveCount(2);
await expect(guides.locator("text")).toHaveCount(0);
await expect(guides).toHaveCSS("opacity", "0.5");
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await page.mouse.up();
await expect(guides).toHaveCount(0);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
const connectedSource = await bounds(moving.locator('.port-handle[data-port-name="port_b"]'));
expect(Math.hypot(connectedSource.cx - target.cx, connectedSource.cy - target.cy)).toBeLessThan(0.7);
});
+24 -37
View File
@@ -1439,7 +1439,6 @@ test("PNCH012 按参考图使用 45% 结构与独立 15px 文字", async ({
test("不同尺寸档位使用紧凑节点框且旋转时保持中心", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const paneBounds = await page.locator(".flow-canvas .react-flow__pane").boundingBox();
expect(paneBounds).not.toBeNull();
@@ -1554,41 +1553,26 @@ test("不同尺寸档位使用紧凑节点框且旋转时保持中心", async ({
);
}
await page.getByRole("button", { name: "开启网格吸附", exact: true }).click();
const snappedTarget = { x: 600, y: 610 };
const snappedNode = await dragSymbolToCanvas(
const placedTarget = { x: 601, y: 613 };
const placedNode = await dragSymbolToCanvas(
page,
"amesim_f000",
snappedTarget,
placedTarget,
);
const snappedBounds = await snappedNode.boundingBox();
expect(snappedBounds).not.toBeNull();
const placedBounds = await placedNode.boundingBox();
expect(placedBounds).not.toBeNull();
expect(
Math.abs(
snappedBounds!.x + snappedBounds!.width / 2 -
(paneBounds!.x + snappedTarget.x),
placedBounds!.x + placedBounds!.width / 2 -
(paneBounds!.x + placedTarget.x),
),
).toBeLessThanOrEqual(10);
).toBeLessThan(2);
expect(
Math.abs(
snappedBounds!.y + snappedBounds!.height / 2 -
(paneBounds!.y + snappedTarget.y),
placedBounds!.y + placedBounds!.height / 2 -
(paneBounds!.y + placedTarget.y),
),
).toBeLessThanOrEqual(10);
await expect
.poll(() =>
page.evaluate(() => {
const raw = window.localStorage.getItem(
"system-simulation-flow:autosave",
);
const project = raw ? JSON.parse(raw) : null;
const position = project?.nodes?.at(-1)?.position;
return position
? { xRemainder: position.x % 18, yRemainder: position.y % 18 }
: null;
}),
)
.toEqual({ xRemainder: 0, yRemainder: 0 });
).toBeLessThan(2);
});
test("LMECHN1 按右侧端口数展开工作区画布且组件库图标保持固定", async ({
@@ -1895,7 +1879,6 @@ test("旧版 LMECHN1 工程会把固定参考端口迁移到动态最大编号",
test("LMECHN1 动态参考端口使用实际锚点参与接触吸附", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const node = await dragSymbolToCanvas(page, "amesim_lmechn1", {
x: 600,
@@ -2953,8 +2936,8 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
await expectSymbolAnchors(firstOrifice, firstOrificeAnchors);
await expectSymbolAnchors(signalOrifice, [
{ portName: "res", x: 27.35, y: 43.2 },
{ portName: "port_2", x: 12.8, y: 24 },
{ portName: "port_3", x: 51.2, y: 24 },
{ portName: "port_2", x: 51.2, y: 24 },
{ portName: "port_3", x: 12.8, y: 24 },
]);
await firstOrifice.locator('[data-symbol-envelope="true"]').click();
@@ -3143,13 +3126,17 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
await signalOutput.click();
await expect(signalOutput).toHaveAttribute("data-connection-state", "origin");
await clickLocatorCenter(page, firstPhysicalOutput);
await expectNoActivePortConnection(page);
await expect(signalOutput).toHaveAttribute("data-connection-state", "origin");
await expect(manualDraft.locator(".manual-connection-waypoint")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(firstPhysicalOutput).toHaveAttribute("data-connected", "true");
await expect(compatiblePhysicalPort).toHaveAttribute("data-connected", "true");
await expect(physicalEdge).not.toHaveClass(/selected|editor-edge-selected/);
await clickLocatorCenter(page, compatiblePhysicalPort);
await expect(signalOutput).toHaveAttribute("data-connection-state", "origin");
await expect(manualDraft.locator(".manual-connection-waypoint")).toHaveCount(2);
await page.keyboard.press("Escape");
await expectNoActivePortConnection(page);
await expect(physicalEdge).not.toHaveClass(/selected|editor-edge-selected/);
@@ -3507,7 +3494,6 @@ test("兼容接口接近时吸附为无连线接触连接,拖开后自动断
page,
}) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const firstOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 280,
@@ -3597,11 +3583,16 @@ test("兼容接口接近时吸附为无连线接触连接,拖开后自动断
await unusedPort.click();
await expect(unusedPort).toHaveAttribute("data-connection-state", "origin");
await page.mouse.click(firstPortCenter.x, firstPortCenter.y);
await expectNoActivePortConnection(page);
// 连线模式下点击已连接端口所在元件只确定转角,不取消连线或改选元件。
await expect(unusedPort).toHaveAttribute("data-connection-state", "origin");
await expect(page.locator(".manual-connection-waypoint")).toHaveCount(1);
await expect(contactEdge).not.toHaveClass(/selected|editor-edge-selected/);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(firstPort).toHaveAttribute("data-connected", "true");
await expect(secondPort).toHaveAttribute("data-connected", "true");
await page.keyboard.press("Escape");
await expectNoActivePortConnection(page);
await secondOrifice.locator(".node-symbol").click();
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
@@ -3658,7 +3649,6 @@ test("PNCH012 四端口接触连接后主体各方向仍可直接选中", async
page,
}) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const chamber = await dragSymbolToCanvas(page, "amesim_pnch012", {
x: 500,
@@ -3835,7 +3825,6 @@ test("信号输出连接后隐藏且拒绝第二条连接", async ({ page }) =>
test("一次拖动会吸附同一落点内的全部兼容接口", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const targetChamber = await dragSymbolToCanvas(page, "amesim_pnch023", {
x: 300,
@@ -3929,7 +3918,6 @@ test("一次拖动会吸附同一落点内的全部兼容接口", async ({ page
test("不同类型接口接近时不会吸附或建立逻辑连接", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const orifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 280,
@@ -3966,7 +3954,6 @@ test("不同类型接口接近时不会吸附或建立逻辑连接", async ({ pa
test("旋转后的接口可以与方向不同的兼容接口接触连接", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const horizontalOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 300,
+271
View File
@@ -0,0 +1,271 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { Position } from "@xyflow/react";
import { componentCatalog, expectAllNodesInsideCanvas, prepareApp, wideProject } from "./fixtures";
import { confirmOrthogonalCorner, extendOrthogonalRoute } from "../../src/edgeRouting";
const PROJECT_KEY = "system-simulation-flow:project:demo-system";
test("每次左键只确认一段并转向,空白预览不插入自动中点", () => {
const source = { x: 0, y: 0 };
const pointer = { x: 120, y: 65 };
const fixed = confirmOrthogonalCorner([source], pointer, Position.Right);
expect(fixed).toEqual([source, { x: 120, y: 0 }]);
expect(extendOrthogonalRoute([source], pointer, Position.Right)).toEqual([
source, { x: 120, y: 0 }, pointer,
]);
expect(extendOrthogonalRoute(fixed, pointer, Position.Right)).toEqual([
source, { x: 120, y: 0 }, pointer,
]);
expect(confirmOrthogonalCorner(fixed, { x: 200, y: 95 }, Position.Right)).toEqual([
source, { x: 120, y: 0 }, { x: 120, y: 95 },
]);
expect(confirmOrthogonalCorner(fixed, { x: 300, y: 0 }, Position.Right)).toBe(fixed);
expect(confirmOrthogonalCorner(fixed, { x: NaN, y: 65 }, Position.Right)).toBe(fixed);
expect(confirmOrthogonalCorner([source], pointer, Position.Bottom)).toEqual([
source, { x: 0, y: 65 },
]);
});
test("各方向端口收尾只调整未确认末段,并保持正交", () => {
for (const sourcePosition of Object.values(Position)) {
for (const targetPosition of Object.values(Position)) {
const fixed = confirmOrthogonalCorner([{ x: 0, y: 0 }], { x: 100, y: 80 }, sourcePosition);
const target = { x: 270, y: 230 };
const route = extendOrthogonalRoute(fixed, target, sourcePosition, targetPosition);
expect(route.slice(0, fixed.length)).toEqual(fixed);
expect(route.at(-1)).toEqual(target);
for (let i = 1; i < route.length; i++) {
expect(route[i].x === route[i - 1].x || route[i].y === route[i - 1].y).toBe(true);
expect(route[i]).not.toEqual(route[i - 1]);
}
const previous = route.at(-2)!;
if (targetPosition === Position.Left || targetPosition === Position.Right) {
expect(previous.y).toBe(target.y);
} else {
expect(previous.x).toBe(target.x);
}
}
}
});
async function loadRoutingProject(page: Page, rotation = 0, reverseSignal = false) {
await prepareApp(page);
if (reverseSignal) {
const catalog = structuredClone(componentCatalog);
const definition = catalog.libraries.flatMap((library) => library.components)
.find((component) => component.type === "generic_sensor")!;
definition.ports = definition.ports.map((port) => ({
...port, kind: "signal", domain: "signal",
nominalRole: port.side === "left" ? "input" : "output",
}));
await page.route("**/api/components/catalog", (route) => route.fulfill({
contentType: "application/json", body: JSON.stringify(catalog),
}));
}
const nodes = [
["origin", 0, 0], ["target", 650, 0], ["obstacle", 280, 260],
["edge-source", 0, 450], ["edge-target", 650, 450],
].map(([id, x, y]) => ({
...structuredClone(wideProject.nodes[0]), id: String(id),
position: { x: Number(x), y: Number(y) },
data: { ...structuredClone(wideProject.nodes[0].data), label: String(id),
rotation: id === "origin" ? rotation : 0 },
}));
const project = { ...structuredClone(wideProject), nodes, edges: [{
...wideProject.edges[0], id: "existing-edge",
source: "edge-source", target: "edge-target",
}] };
await page.addInitScript(({ key, value }) => {
localStorage.setItem(key, JSON.stringify(value));
}, { key: PROJECT_KEY, value: project });
await page.goto("/");
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(5);
await expectAllNodesInsideCanvas(page, ".flow-canvas", 5);
const output = page.locator('.react-flow__node[data-id="origin"] .port-handle[data-port-name="port_b"]');
const input = page.locator('.react-flow__node[data-id="target"] .port-handle[data-port-name="port_a"]');
return {
origin: reverseSignal ? input : output,
target: reverseSignal ? output : input,
draft: page.locator(".manual-connection-draft .react-flow__connection-path"),
};
}
async function center(locator: Locator) {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
return { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 };
}
async function pathPoints(path: Locator) {
const values = (await path.getAttribute("d"))!.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)!.map(Number);
return Array.from({ length: values.length / 2 }, (_, index) => ({
x: values[index * 2], y: values[index * 2 + 1],
}));
}
for (const { rotation, reverseSignal } of [
{ rotation: 0, reverseSignal: false },
{ rotation: 90, reverseSignal: false },
{ rotation: 0, reverseSignal: true },
]) {
test(`逐段定转折后已确认部分保持固定(${reverseSignal ? "信号输入端反向起画" : `旋转 ${rotation}°`})`, async ({ page }) => {
const { origin, target, draft } = await loadRoutingProject(page, rotation, reverseSignal);
const start = await center(origin);
const waypoint = {
x: Math.round(start.x + (reverseSignal ? -120 : 120)),
y: Math.round(start.y + 65),
};
await origin.click();
await page.mouse.move(waypoint.x, waypoint.y);
await expect(draft).toBeVisible();
const beforeClick = await pathPoints(draft);
// 初始为 L 形橡皮筋,一次点击只确认第一个转角,不固定末端。
expect(beforeClick).toHaveLength(3);
const firstFixed = beforeClick.slice(0, -1);
await page.mouse.click(waypoint.x, waypoint.y);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
expect(await pathPoints(draft)).toEqual(beforeClick);
for (const offset of [{x: 100, y: 30}, {x: -45, y: 110}, {x: 90, y: -55}]) {
await page.mouse.move(waypoint.x + offset.x, waypoint.y + offset.y);
await expect.poll(async () => (await pathPoints(draft)).slice(0, firstFixed.length)).toEqual(firstFixed);
}
const secondPoint = { x: waypoint.x + 90, y: waypoint.y - 55 };
const secondPreview = await pathPoints(draft);
const secondFixed = secondPreview.slice(0, -1);
await page.mouse.click(secondPoint.x, secondPoint.y);
await expect(draft).toHaveAttribute("data-waypoint-count", "2");
expect(await pathPoints(draft)).toEqual(secondPreview);
await page.mouse.move(secondPoint.x + 40, secondPoint.y + 110);
await expect.poll(async () => (await pathPoints(draft)).slice(0, secondFixed.length)).toEqual(secondFixed);
await target.hover();
const completedPreview = await pathPoints(draft);
expect(completedPreview.slice(0, secondFixed.length)).toEqual(secondFixed);
await target.click();
await expect(draft).toHaveCount(0);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const persisted = await page.evaluate((key) => {
const project = JSON.parse(localStorage.getItem(key)!);
return project.edges.find((edge: { id: string }) => edge.id !== "existing-edge").data.routePoints;
}, PROJECT_KEY);
expect(persisted).toEqual((reverseSignal ? completedPreview.reverse() : completedPreview).slice(1, -1));
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
const finalizedPath = page.locator('.flow-canvas .react-flow__edge:not([data-id="existing-edge"]) .react-flow__edge-path');
const maximumDeviation = await finalizedPath.evaluate((path: SVGPathElement, fixed) => {
const samples = Array.from({ length: Math.ceil(path.getTotalLength()) + 1 }, (_, index) =>
path.getPointAtLength(index),
);
return Math.max(...fixed.map((point) =>
Math.min(...samples.map((sample) => Math.hypot(sample.x - point.x, sample.y - point.y))),
));
}, secondFixed.slice(1));
expect(maximumDeviation).toBeLessThan(1);
});
}
test("右键逐段退回并恢复绘制方向,退到起点后再次右键取消", async ({ page }) => {
const { origin, target, draft } = await loadRoutingProject(page);
const start = await center(origin);
await origin.click();
await page.mouse.click(start.x + 120, start.y + 65);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
const firstFixed = (await pathPoints(draft)).slice(0, 2);
await page.mouse.click(start.x + 200, start.y + 120);
await expect(draft).toHaveAttribute("data-waypoint-count", "2");
await page.mouse.click(start.x + 250, start.y + 180, { button: "right" });
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
const afterUndo = await pathPoints(draft);
expect(afterUndo.slice(0, 2)).toEqual(firstFixed);
expect(afterUndo[2].x).toBe(firstFixed[1].x); // 恢复为纵向绘制
await page.mouse.click(start.x + 250, start.y + 180, { button: "right" });
await expect(draft).toHaveAttribute("data-waypoint-count", "0");
const atStart = await pathPoints(draft);
expect(atStart[1].y).toBe(atStart[0].y); // 恢复起始端口的横向
await page.mouse.click(start.x + 250, start.y + 180, { button: "right" });
await expect(draft).toHaveCount(0);
await expect(origin).toHaveAttribute("data-connection-state", "idle");
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
// 取消的草稿不生成线路,下一次连接不受 React Flow 的点击状态干扰。
await origin.click();
await target.click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
await expect(draft).toHaveCount(0);
});
test("绘线时缩放、滚动条和中键平移不会确认额外转角或改变已确认路径", async ({ page }) => {
const { origin, target, draft } = await loadRoutingProject(page);
const start = await center(origin);
await origin.click();
await page.mouse.click(start.x + 120, start.y + 65);
const fixed = (await pathPoints(draft)).slice(0, 2);
const viewport = page.locator(".flow-canvas .react-flow__viewport");
const beforeZoom = await viewport.getAttribute("style");
await page.keyboard.down("Control");
await page.mouse.wheel(0, 240);
await page.keyboard.up("Control");
await expect(viewport).not.toHaveAttribute("style", beforeZoom!);
expect((await pathPoints(draft)).slice(0, 2)).toEqual(fixed);
const scrollbar = page.getByRole("scrollbar", { name: "水平滚动画布" });
const beforeScroll = await viewport.getAttribute("style");
await scrollbar.focus();
await page.keyboard.press("ArrowRight");
await expect(viewport).not.toHaveAttribute("style", beforeScroll!);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
expect((await pathPoints(draft)).slice(0, 2)).toEqual(fixed);
const beforePan = await viewport.getAttribute("style");
await page.mouse.move(start.x + 180, start.y + 180);
await page.mouse.down({ button: "middle" });
await page.mouse.move(start.x + 220, start.y + 200, { steps: 4 });
await page.mouse.up({ button: "middle" });
await expect(viewport).not.toHaveAttribute("style", beforePan!);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
expect((await pathPoints(draft)).slice(0, 2)).toEqual(fixed);
await target.click();
await expect(draft).toHaveCount(0);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
});
test("连接模式点击元件和已有连线只添加折点,取消后恢复选择", async ({ page }) => {
const { origin, target, draft } = await loadRoutingProject(page);
const obstacle = page.locator('.react-flow__node[data-id="obstacle"]');
const obstacleCenter = await center(obstacle.locator(".sim-node"));
const edge = page.locator('.react-flow__edge[data-id="existing-edge"]');
const edgeCenter = await edge.locator(".react-flow__edge-path").evaluate((path: SVGPathElement) => {
const p = path.getPointAtLength(path.getTotalLength() / 2).matrixTransform(path.getScreenCTM()!);
return { x: p.x, y: p.y };
});
await page.mouse.click(edgeCenter.x, edgeCenter.y);
await expect(edge).toHaveClass(/selected|editor-edge-selected/);
await origin.click();
await expect(origin).toHaveAttribute("data-connection-state", "origin");
await page.mouse.click(obstacleCenter.x, obstacleCenter.y);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
await expect(obstacle).not.toHaveClass(/selected/);
await page.mouse.click(edgeCenter.x, edgeCenter.y);
await expect(draft).toHaveAttribute("data-waypoint-count", "2");
await expect(edge).not.toHaveClass(/selected|editor-edge-selected/);
await expect(page.locator(".manual-edge-segment-handle, .manual-edge-corner-handle, .manual-edge-route-handle")).toHaveCount(0);
await expect(origin).toHaveAttribute("data-connection-state", "origin");
await expect(target).toHaveAttribute("data-connection-state", "compatible");
const beforeDrag = await obstacle.getAttribute("style");
await page.mouse.move(obstacleCenter.x, obstacleCenter.y);
await page.mouse.down();
await page.mouse.move(obstacleCenter.x + 35, obstacleCenter.y + 25, { steps: 3 });
await page.mouse.up();
expect(await obstacle.getAttribute("style")).toBe(beforeDrag);
await expect(page.locator(".react-flow__selection")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(draft).toHaveCount(0);
await page.mouse.click(obstacleCenter.x, obstacleCenter.y);
await expect(obstacle).toHaveClass(/selected/);
await page.mouse.click(edgeCenter.x, edgeCenter.y);
await expect(edge).toHaveClass(/selected|editor-edge-selected/);
});
@@ -130,7 +130,6 @@ test("多选块拖动会即时断开边界接触,并由块内空闲端口整
page,
}) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const fixedNode = await dragGenericToCanvas(page, { x: 150, y: 180 });
const firstMovingNode = await dragGenericToCanvas(page, { x: 410, y: 180 });
@@ -211,7 +210,6 @@ test("复制块保留内部连接,同时未连接端口仍参与待放置吸
page,
}) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const first = await dragGenericToCanvas(page, { x: 170, y: 180 });
const second = await dragGenericToCanvas(page, { x: 410, y: 370 });
+5 -5
View File
@@ -490,7 +490,7 @@ test("恢复自动保存工程后自动适配建模画布", async ({ page }) =>
await expect(consolePanel).toContainText("已恢复上次自动保存的工程");
});
test("切换到结果页时自动适配只读系统图", async ({ page }) => {
test("切换到结果页时自动适配只读系统图,旧网格线设置不再生效", async ({ page }) => {
await prepareApp(page);
await page.addInitScript(({ project, snapshot }) => {
window.sessionStorage.setItem(
@@ -513,7 +513,7 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
page.locator(
'.flow-canvas .react-flow__background pattern[id$="major-grid-lines"]',
),
).toHaveCount(1);
).toHaveCount(0);
await expect(
page.locator(
'.flow-canvas .react-flow__background pattern[id$="minor-grid-dots"]',
@@ -533,7 +533,7 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
page.locator(
'.results-system-canvas .react-flow__background pattern[id$="major-grid-lines"]',
),
).toHaveCount(1);
).toHaveCount(0);
await expect(
page.locator(
'.results-system-canvas .react-flow__background pattern[id$="minor-grid-dots"]',
@@ -659,8 +659,8 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
}),
);
for (const shape of idleResultPortShapes) {
expect(Math.abs(shape.width - 6)).toBeLessThan(0.05);
expect(Math.abs(shape.height - 6)).toBeLessThan(0.05);
expect(Math.abs(shape.width - 6 * resultViewportZoom)).toBeLessThan(0.05);
expect(Math.abs(shape.height - 6 * resultViewportZoom)).toBeLessThan(0.05);
expect(shape.glyphWidth).not.toBeNull();
expect(shape.glyphHeight).not.toBeNull();
expect(
+66
View File
@@ -0,0 +1,66 @@
import { expect, test, type Locator } from "@playwright/test";
import { prepareApp } from "./fixtures";
async function bounds(locator: Locator) {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
return { ...box!, cx: box!.x + box!.width / 2, cy: box!.y + box!.height / 2 };
}
test("不再提供网格吸附或网格线开关,拖入、微移和粘贴不再对网格取整", async ({ page }) => {
await prepareApp(page);
await page.goto("/");
await expect(page.getByRole("button", { name: /网格吸附|主网格线/ })).toHaveCount(0);
await expect(page.locator('pattern[id$="major-grid-lines"]')).toHaveCount(0);
await expect(page.locator('pattern[id$="minor-grid-dots"]')).toHaveCount(1);
const pane = page.locator(".flow-canvas .react-flow__pane");
const canvas = await bounds(pane);
const drop = { x: 231, y: 197 };
await page.getByRole("button", { name: /通用测试元件/ }).dragTo(pane, { targetPosition: drop });
const node = page.locator('.flow-canvas .react-flow__node[data-id="generic_sensor_1"]');
const initial = await bounds(node);
expect(initial.cx).toBeCloseTo(canvas.x + drop.x, 0);
expect(initial.cy).toBeCloseTo(canvas.y + drop.y, 0);
// 先跨过 React Flow 的拖拽阈值,再移动少于原网格间距的距离。
await page.mouse.move(initial.cx, initial.cy);
await page.mouse.down();
await page.mouse.move(initial.cx, initial.cy + 5);
const started = await bounds(node);
await page.mouse.move(initial.cx + 7, initial.cy + 16);
await page.mouse.up();
const moved = await bounds(node);
expect(moved.x - started.x).toBeCloseTo(7, 0);
expect(moved.y - started.y).toBeCloseTo(11, 0);
await page.keyboard.press("Control+c");
await page.keyboard.press("Control+v");
const pointer = { x: canvas.x + 493, y: canvas.y + 371 };
await page.mouse.move(pointer.x, pointer.y);
const preview = page.locator(".flow-canvas .pending-paste-node");
await expect(preview).toBeVisible();
const pending = await bounds(preview);
expect(pending.cx).toBeCloseTo(pointer.x, 0);
expect(pending.cy).toBeCloseTo(pointer.y, 0);
await page.mouse.click(pointer.x, pointer.y);
await expect(preview).toHaveCount(0);
const placed = await bounds(page.locator('.react-flow__node[data-id="generic_sensor_2"]'));
expect(placed.cx).toBeCloseTo(pointer.x, 0);
expect(placed.cy).toBeCloseTo(pointer.y, 0);
});
test("忽略旧版网格线开关并保留网格点偏好", async ({ page }) => {
await prepareApp(page);
await page.addInitScript(() => localStorage.setItem(
"system-simulation-flow:canvas-grid-visibility", JSON.stringify({ lines: true, dots: false }),
));
await page.goto("/");
await expect(page.locator(".flow-canvas .react-flow__background")).toHaveCount(0);
await expect(page.getByRole("button", { name: /网格吸附|主网格线/ })).toHaveCount(0);
await page.getByRole("button", { name: "显示网格点", exact: true }).click();
await expect(page.locator('pattern[id$="minor-grid-dots"]')).toHaveCount(1);
await expect(page.locator('pattern[id$="major-grid-lines"]')).toHaveCount(0);
await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem(
"system-simulation-flow:canvas-grid-visibility",
)!))).toEqual({ dots: true });
});
+6 -1
View File
@@ -32,7 +32,12 @@ for (const diagnosticsMode of ["native", "legacy-native", "python"] as const) {
} else {
await expect(consolePanel).not.toContainText("最大方程残差");
}
const saved = await page.evaluate(() => JSON.parse(sessionStorage.getItem("system-simulation-flow:latest-result") ?? "null"));
await expect.poll(() => page.evaluate(() => JSON.parse(sessionStorage.getItem("system-simulation-flow:latest-result") ?? "null")?.storage)).toBe("indexeddb");
const saved = await page.evaluate(async () => {
// @ts-expect-error Vite serves the persistence module in the test browser.
const { loadStoredResultSnapshot } = await import("/src/resultPersistence.ts");
return loadStoredResultSnapshot();
});
expect(saved.result.diagnostics.sampleCount).toBe(3);
expect(saved.result.series).toEqual(resultSnapshot.result.series);
await page.reload();
+122
View File
@@ -0,0 +1,122 @@
import { expect, test, type Page } from "@playwright/test";
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
normalizePortComputation, portConnectionIssue, referenceSupplyIssues,
} from "../../src/portComputation";
import { expandSimulationConsole, prepareApp } from "./fixtures";
const root = fileURLToPath(new URL("../../..", import.meta.url));
const localPython = resolve(root, ".venv-win/Scripts/python.exe");
const python = process.env.TEST_PYTHON ?? (existsSync(localPython) ? localPython : "python");
// Use real backend metadata: frontend/backend drift must fail this test.
const catalog = JSON.parse(execFileSync(python, ["-c",
"import json; from app.simulation.registry import build_component_catalog; print(json.dumps(build_component_catalog()))",
], { cwd: root, encoding: "utf8" }));
const definitions = new Map<string, any>(catalog.libraries.flatMap((library: any) =>
library.components.map((component: any) => [component.modelType, component])));
const port = (model: string, name: string) => definitions.get(model).ports.find((p: any) => p.name === name);
function node(id: string, model: string, x: number, y = 0) {
const definition = definitions.get(model);
return { id, type: "simulationComponent", position: { x, y }, data: {
label: id, componentType: model, modelType: model, modelVersion: definition.modelVersion,
symbol: definition.symbol, ports: definition.ports.map((p: any) => ({ ...p })),
parameters: Object.fromEntries(definition.parameters.map((p: any) => [p.name, p.name === "gi" ? 0 : p.default])),
parameterUnits: Object.fromEntries(definition.parameters.map((p: any) => [p.name, p.unit])),
rotation: 0, mirrored: false,
} };
}
function project(wrong = false) {
const nodes = [node("junction", "amesim_p4node2", 330),
node("valve", "amesim_pnvo001_fixed", 0), node("storage", "amesim_pnl0001", 660)];
if (wrong) {
// A forged/old snapshot must not replace the current catalog contract.
nodes[0].data.ports.forEach((p: any) => { delete p.computation; });
}
return { projectSchemaVersion: 1, name: "port-supply-e2e", nodes,
edges: wrong ? [
{ id: "wrong-reference", source: "junction", sourceHandle: "port_2",
target: "valve", targetHandle: "port_2", data: { isContactEdge: false } },
{ id: "wrong-branch", source: "junction", sourceHandle: "port_1",
target: "storage", targetHandle: "port_2", data: { isContactEdge: false } },
] : [],
simulation: { t_start: 0, t_stop: 0.01, step: 0.001, max_step: 0.001, method: "RK45" },
};
}
async function load(page: Page, value: object) {
await prepareApp(page);
await page.route("**/api/components/catalog", (route) => route.fulfill({
contentType: "application/json", body: JSON.stringify(catalog),
}));
await page.goto("/");
await expect(page.locator(".catalog-source.ready")).toBeVisible();
await page.locator('input[type="file"]').setInputFiles({
name: "port-supply.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(value)),
});
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(3);
}
test("真实目录的参考口/支路口供需在两种绘线方向下一致", () => {
const reference = port("amesim_p4node2", "port_2");
const branch = port("amesim_p4node2", "port_1");
const state = port("amesim_pnl0001", "port_2");
const flow = port("amesim_pnvo001_fixed", "port_2");
for (const [a, b] of [[reference, state], [branch, flow]]) {
expect(portConnectionIssue(a, b)).toBeNull();
expect(portConnectionIssue(b, a)).toBeNull();
}
for (const [a, b] of [[reference, flow], [flow, reference]]) {
expect(portConnectionIssue(a, b)).toContain("温度");
expect(portConnectionIssue(a, b)).toContain("压力");
}
expect(portConnectionIssue(branch, state)).toContain("能量流率");
expect(portConnectionIssue(flow, port("amesim_pnl00r", "port_1"))).toBeNull();
expect(() => normalizePortComputation({ mode: "fixed", inputs: ["T"], outputs: ["T"] })).toThrow();
});
test("参考链能追溯储气状态,拒绝无来源循环", () => {
const nodes = [node("a", "amesim_p4node2", 0), node("b", "amesim_p4node2", 300),
node("state", "amesim_pnl0001", 600)];
const edges = [{ source: "a", sourceHandle: "port_2", target: "b", targetHandle: "port_1" },
{ source: "b", sourceHandle: "port_2", target: "state", targetHandle: "port_2" }];
expect(referenceSupplyIssues(nodes, edges)).toEqual([]);
edges[1] = { source: "b", sourceHandle: "port_2", target: "a", targetHandle: "port_1" };
expect(referenceSupplyIssues(nodes, edges)).toHaveLength(4);
expect(referenceSupplyIssues(nodes, edges)[0].message).toContain("没有实际提供者");
});
test("画布按变量供需过滤接线,并说明不兼容的具体原因", async ({ page }) => {
await load(page, project());
const handle = (id: string, name: string) => page.locator(
`.react-flow__node[data-id="${id}"] .port-handle[data-port-name="${name}"]`);
const reference = handle("junction", "port_2");
await expect(reference).toHaveAttribute("title", /温度、压力参考口/);
await reference.click();
await expect(handle("valve", "port_2")).toHaveAttribute("data-connection-state", "incompatible");
await expect(handle("valve", "port_2")).toHaveAttribute("title", /需要对端提供压力、温度/);
await expect(handle("storage", "port_2")).toHaveAttribute("data-connection-state", "compatible");
await handle("storage", "port_2").click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
});
test("导入时保留供需错误连线,模型检查阻止仿真且不信任旧端口快照", async ({ page }) => {
let simulations = 0;
await page.route("**/api/system-xml/simulate-stream", (route) => {
simulations += 1;
return route.fulfill({ status: 500, body: "unexpected simulation" });
});
await load(page, project(true));
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
await page.getByRole("button", { name: "运行仿真", exact: true }).click();
await expandSimulationConsole(page);
const panel = page.getByRole("complementary", { name: "仿真控制台", exact: true });
await expect(panel).toContainText("需要对端提供压力、温度");
await expect(panel).toContainText("质量流率、能量流率");
expect(simulations).toBe(0);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
});
@@ -0,0 +1,234 @@
import { expect, test, type Page } from "@playwright/test";
import { chartDisplaySamples, chartSampleBounds, preparedChartSamples, sampledChartPath } from "../../src/chartData";
import { resultSnapshot, prepareApp } from "./fixtures";
test("million-point plots preserve extrema and raw indices within a pixel-sized path", () => {
const time = Array.from({ length: 1_000_000 }, (_, i) => i * 0.001);
const values = time.map((t) => Math.sin(t));
values[123456] = 1e9;
values[654321] = -1e9;
const started = performance.now();
const samples = preparedChartSamples(time, values);
const displayed = chartDisplaySamples(samples, 0, 1000, 800);
expect(chartSampleBounds(samples)).toMatchObject({ yMin: -1e9, yMax: 1e9 });
expect(displayed.some((sample) => sample.dataIndex === 123456 && sample.y === 1e9)).toBe(true);
expect(displayed.some((sample) => sample.dataIndex === 654321 && sample.y === -1e9)).toBe(true);
expect(displayed.length).toBeLessThanOrEqual(4 * 802);
expect(preparedChartSamples(time, values)).toBe(samples);
expect(chartDisplaySamples(samples, 0, 1000, 800)).toBe(displayed);
const narrow = chartDisplaySamples(samples, 123.450, 123.465, 800);
expect(narrow.some((sample) => sample.dataIndex === 123456)).toBe(true);
expect(narrow.length).toBeLessThan(20);
expect(displayed.every((sample) => sample.y === values[sample.dataIndex])).toBe(true);
console.log(JSON.stringify({ probe: "million-point-chart", rawPoints: samples.length, displayPoints: displayed.length, elapsedMs: performance.now() - started }));
});
test("pixel decimation preserves gaps, duplicate-time jumps, boundaries and unsorted input", () => {
const time = Array.from({ length: 10000 }, (_, i) => i);
const values = time.map(() => 0);
time[4000] = Number.NaN;
time[5001] = time[5000];
values[5001] = 999;
const samples = preparedChartSamples(time, values);
const display = chartDisplaySamples(samples, 0, 10000, 10);
expect(display.some((sample) => sample.breakBefore && sample.dataIndex === 4001)).toBe(true);
expect(display.some((sample) => sample.dataIndex === 5001 && sample.y === 999)).toBe(true);
const path = sampledChartPath(time, values, (x) => x, (y) => y, 0, 10000, 10);
expect(path.match(/\bM /g)).toHaveLength(2);
const crossing = chartDisplaySamples(preparedChartSamples([0, 10], [-10, 10]), 4, 6, 10);
expect(crossing.map((sample) => sample.x)).toEqual([0, 10]);
const unsorted = preparedChartSamples([2, 0, 1], [20, 0, 10]);
expect(chartDisplaySamples(unsorted, 0, 2, 1)).toEqual(unsorted);
});
async function probePage(page: Page) {
await prepareApp(page);
await page.route("**/result-performance-probe", (route) => route.fulfill({ contentType: "text/html", body: "<title>Result probe</title>" }));
await page.goto("/result-performance-probe");
}
test("chunked persistence restores one million doubles exactly beyond sessionStorage quota", async ({ page }) => {
await probePage(page);
const measurement = await page.evaluate(async (template) => {
// @ts-expect-error browser-side Vite module
const { storeResultSnapshot, loadStoredResultSnapshot, resultSavePending } = await import("/src/resultPersistence.ts");
const values = Array.from({ length: 1_000_000 }, (_, i) => Math.sin(i * 0.013) * 1e5);
const snapshot = { ...template, result: { ...template.result, series: { time: [0, 1], values, empty: [] } } };
const serialized = JSON.stringify(snapshot);
const originalStringify = JSON.stringify;
let largeStringifyCalls = 0;
JSON.stringify = function(value: unknown, ...args: any[]) {
if ((value as any)?.result?.series || (value as any)?.snapshot?.result?.series) largeStringifyCalls++;
return (originalStringify as any)(value, ...args);
} as typeof JSON.stringify;
let ticks = 0;
const timer = setInterval(() => { ticks++; }, 0);
const start = performance.now();
const saving = storeResultSnapshot(snapshot);
const savingImmediately = resultSavePending();
await saving;
const writeMs = performance.now() - start;
clearInterval(timer);
JSON.stringify = originalStringify;
const readStart = performance.now();
const loaded = await loadStoredResultSnapshot();
return {
rawJsonBytes: serialized.length, writeMs, readMs: performance.now() - readStart, ticks,
savingImmediately, pendingAfter: resultSavePending(), largeStringifyCalls,
markerBytes: sessionStorage.getItem("system-simulation-flow:latest-result")!.length,
equal: loaded.result.series.values.length === values.length && loaded.result.series.values.every((value: number, i: number) => value === values[i]),
empty: loaded.result.series.empty,
};
}, resultSnapshot);
expect(measurement).toMatchObject({ equal: true, savingImmediately: true, pendingAfter: false, largeStringifyCalls: 0, empty: [] });
expect(measurement.ticks).toBeGreaterThan(1);
expect(measurement.markerBytes).toBeLessThan(200);
console.log(JSON.stringify({ probe: "large-result-persistence", ...measurement }));
await page.reload();
expect(await page.evaluate(async () => {
// @ts-expect-error browser-side Vite module
const { loadStoredResultSnapshot } = await import("/src/resultPersistence.ts");
return (await loadStoredResultSnapshot()).result.series.values.length;
})).toBe(1_000_000);
});
test("failed and superseded saves preserve the last complete snapshot", async ({ page }) => {
await probePage(page);
const outcome = await page.evaluate(async (template) => {
// @ts-expect-error browser-side Vite module
const { storeResultSnapshot, loadStoredResultSnapshot, resultSavePending } = await import("/src/resultPersistence.ts");
await storeResultSnapshot(template);
const firstMarker = sessionStorage.getItem("system-simulation-flow:latest-result");
const originalPut = IDBObjectStore.prototype.put;
IDBObjectStore.prototype.put = function() { throw new DOMException("Injected quota failure", "QuotaExceededError"); };
let failed = false;
try { await storeResultSnapshot({ ...template, id: "failed" }); } catch { failed = true; }
IDBObjectStore.prototype.put = originalPut;
const preserved = firstMarker === sessionStorage.getItem("system-simulation-flow:latest-result") && (await loadStoredResultSnapshot()).id === template.id;
const old = storeResultSnapshot({ ...template, id: "older", result: { ...template.result, series: { time: Array.from({ length: 200000 }, (_, i) => i) } } });
const latest = storeResultSnapshot({ ...template, id: "newest" });
const saved = await Promise.all([old, latest]);
return { failed, preserved, saved, newest: (await loadStoredResultSnapshot()).id, pending: resultSavePending() };
}, resultSnapshot);
expect(outcome).toEqual({ failed: true, preserved: true, saved: [false, true], newest: "newest", pending: false });
});
async function prepareLargeResult(page: Page) {
await probePage(page);
await page.evaluate(async (template) => {
// @ts-expect-error browser-side Vite module
const { storeResultSnapshot } = await import("/src/resultPersistence.ts");
const count = 250000;
const time = Array.from({ length: count }, (_, i) => i * 0.001);
const series: Record<string, number[]> = { time };
const variables = Array.from({ length: 3 }, (_, index) => ({
...template.result.variables[0], key: `generic_sensor_1.stress_${index}`, name: `stress_${index}`,
label: `压力${index}`, quantity: "pressure", unit: "Pa", order: index,
}));
variables.forEach((variable, index) => { series[variable.key] = time.map((t) => 100000 + Math.sin(t + index) * 1000); });
series[variables[0].key][123456] = 1e9;
const snapshot = { ...template, id: "large-chart-stress", result: { ...template.result, variables, series, diagnostics: { ...template.result.diagnostics, sampleCount: count } } };
await storeResultSnapshot(snapshot);
}, resultSnapshot);
await page.goto("/");
await page.getByRole("tab", { name: "结果", exact: true }).click();
await expect(page.locator(".results-variable-list button")).toHaveCount(3);
}
test("250,000-sample single, multi and stacked charts stay bounded and export full raw data", async ({ page }) => {
test.setTimeout(90000);
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await prepareLargeResult(page);
const variables = page.locator(".results-variable-list button");
await variables.first().click();
const single = page.locator('.result-chart-window[data-chart-kind="single"]');
await expect(single).toBeVisible();
const svg = single.locator('svg[data-result-chart="true"]');
expect(Number(await svg.getAttribute("data-view-y-max"))).toBeGreaterThan(1e9);
const firstPath = await single.locator('path[data-chart-curve="true"], g[data-chart-clipped-series="true"] path, path[data-chart-clipped-series="true"]').first().getAttribute("d");
expect((firstPath!.match(/\b[ML] /g) ?? []).length).toBeLessThan(5000);
await single.getByRole("combobox", { name: "压力0纵轴单位", exact: true }).selectOption("kPa");
expect(Number(await svg.getAttribute("data-view-y-max"))).toBeGreaterThan(1e6);
await single.getByRole("button", { name: "打开 压力0 曲线游标", exact: true }).click();
const readings = await single.locator(".result-chart-cursor-panel dd").allTextContents();
const readTime = Number.parseFloat(readings[0]);
const readValue = Number.parseFloat(readings[1]);
expect(readTime).toBe(125);
expect(readValue).toBeCloseTo((100000 + Math.sin(125) * 1000) / 1000, 4);
// X-axis wheel zoom must redraw only the viewport and keep the exact raw samples.
await single.getByRole("button", { name: "打开 压力0 曲线缩放", exact: true }).click();
const xAxis = svg.locator('[data-chart-zoom-axis="x"]');
const bounds = await xAxis.boundingBox();
expect(bounds).not.toBeNull();
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2);
await page.mouse.wheel(0, -160);
await expect(svg).toHaveAttribute("data-zoomed", "true");
await single.getByRole("button", { name: "恢复 压力0 原始尺寸", exact: true }).click();
await page.getByRole("button", { name: "新建同单位多曲线对比窗口", exact: true }).click();
const multi = page.locator('.result-chart-window[data-chart-kind="multi"]');
await variables.nth(0).dragTo(multi);
await variables.nth(1).dragTo(multi);
await expect(multi).toContainText("2/2 条显示");
await page.getByRole("button", { name: "新建异单位上下对比窗口", exact: true }).click();
const mixed = page.locator('.result-chart-window[data-chart-kind="mixed"]');
await variables.nth(1).dragTo(mixed);
await variables.nth(2).dragTo(mixed);
await expect(mixed).toContainText("2/2 条显示");
const paths = await page.locator('g[data-chart-clipped-series="true"] path, path[data-chart-clipped-series="true"]').evaluateAll((elements) => elements.map((element) => (element.getAttribute("d")?.match(/\b[ML] /g) ?? []).length));
expect(paths.length).toBeGreaterThanOrEqual(5);
expect(paths.every((count) => count < 10000)).toBe(true);
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "下载结果文件", exact: true }).click();
const download = await downloadPromise;
const stream = await download.createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of stream!) chunks.push(Buffer.from(chunk));
const exported = JSON.parse(Buffer.concat(chunks).toString("utf8"));
expect(exported.snapshot.result.series.time).toHaveLength(250000);
expect(exported.snapshot.result.series["generic_sensor_1.stress_0"][123456]).toBe(1e9);
// Compare the exact original browser data, not Math.sin recomputed in Node's V8.
const rawMatches = await page.evaluate(async (exportedSeries) => {
// @ts-expect-error browser-side Vite module
const { loadStoredResultSnapshot } = await import("/src/resultPersistence.ts");
const raw = (await loadStoredResultSnapshot()).result.series;
return Object.entries(exportedSeries).every(([key, values]) => {
const numericValues = values as number[];
return numericValues.length === raw[key].length && numericValues.every((value, i) => Object.is(value, raw[key][i]));
});
}, exported.snapshot.result.series);
expect(rawMatches).toBe(true);
await page.reload();
await page.getByRole("tab", { name: "结果", exact: true }).click();
await expect(page.locator(".result-chart-window")).toHaveCount(3);
expect(errors).toEqual([]);
console.log(JSON.stringify({ probe: "large-multi-chart", pointsPerCurve: 250000, paths }));
});
test("browser chart benchmark keeps redraw paths small without resampling raw cursor data", async ({ page }) => {
await probePage(page);
const metrics = await page.evaluate(async () => {
// @ts-expect-error browser-side Vite module
const { sampledChartPath, preparedChartSamples } = await import("/src/chartData.ts");
// @ts-expect-error browser-side Vite module
const { segmentedChartPath } = await import("/src/resultEventSeries.ts");
const time = Array.from({ length: 250000 }, (_, i) => i * .001);
const values = time.map((x) => Math.sin(x));
const x = (value: number) => value / 250 * 800;
const y = (value: number) => 200 - value * 100;
const start = performance.now();
const oldPath = segmentedChartPath(time, values, x, y);
const oldMs = performance.now() - start;
const first = performance.now();
const newPath = sampledChartPath(time, values, x, y, 0, 250, 800);
const firstMs = performance.now() - first;
const redraw = performance.now();
for (let i = 0; i < 100; i++) sampledChartPath(time, values, x, y, 0, 250, 800);
const redrawMs = (performance.now() - redraw) / 100;
return { points: time.length, oldMs, firstMs, redrawMs, oldCommands: (oldPath.match(/\b[ML] /g) ?? []).length, newCommands: (newPath.match(/\b[ML] /g) ?? []).length, rawSamples: preparedChartSamples(time, values).length };
});
expect(metrics.rawSamples).toBe(250000);
expect(metrics.newCommands).toBeLessThan(3209);
expect(metrics.oldCommands).toBe(250000);
console.log(JSON.stringify({ probe: "browser-chart-comparison", ...metrics }));
});
@@ -0,0 +1,328 @@
import { expect, test, type Page } from "@playwright/test";
import { createNdjsonLineDecoder } from "../../src/ndjsonStream";
import { prepareApp, resultSnapshot, wideProject } from "./fixtures";
test("NDJSON preserves UTF-8, CRLF, empty lines and an unterminated final result", () => {
const events = [
{ event: "progress", message: "正在仿真 🚀", progress: 5 },
{ event: "result", result: { values: [1, -2.3e-12, null], message: "第一行\n第二行" } },
];
const bytes = new TextEncoder().encode(`\n${JSON.stringify(events[0])}\r\n\n${JSON.stringify(events[1])}`);
for (const size of [1, 2, 3, 7, 64, bytes.length]) {
const decoded: unknown[] = [];
const decoder = createNdjsonLineDecoder((line) => {
if (line.trim()) decoded.push(JSON.parse(line));
});
for (let offset = 0; offset < bytes.length; offset += size) {
decoder.write(bytes.subarray(offset, offset + size));
}
decoder.finish();
decoder.finish();
expect(decoded).toEqual(events);
}
});
test("NDJSON immediately propagates errors without consuming later events", () => {
const seen: unknown[] = [];
const decoder = createNdjsonLineDecoder((line) => seen.push(JSON.parse(line)));
expect(() => decoder.write(new TextEncoder().encode('1\ninvalid\n2\n'))).toThrow();
expect(seen).toEqual([1]);
const truncated = createNdjsonLineDecoder((line) => JSON.parse(line));
truncated.write(new TextEncoder().encode('{"event":"result"'));
expect(() => truncated.finish()).toThrow();
});
test("large NDJSON stress preserves every number across 1 KiB and 64 KiB chunks", () => {
const values = Array.from({ length: 1_000_000 }, (_, i) => Math.sin(i * 0.013) * 1e5);
const expected = { event: "result", result: { message: "大结果", values } };
const bytes = new TextEncoder().encode(JSON.stringify(expected) + "\n");
for (const size of [1024, 65536]) {
let decoded: typeof expected | undefined;
let calls = 0;
const start = performance.now();
const decoder = createNdjsonLineDecoder((line) => {
calls += 1;
decoded = JSON.parse(line);
});
for (let offset = 0; offset < bytes.length; offset += size) {
decoder.write(bytes.subarray(offset, offset + size));
}
decoder.finish();
const elapsedMs = performance.now() - start;
expect(calls).toBe(1);
expect(decoded?.result.message).toBe("大结果");
expect(decoded?.result.values.length).toBe(values.length);
// Avoid a million matcher calls; compare every sample, not just the endpoints.
expect(decoded!.result.values.every((value, index) => value === values[index])).toBe(true);
console.log(JSON.stringify({ probe: "ndjson-million-values", bytes: bytes.length, chunkBytes: size, elapsedMs }));
}
});
test("100,000 progress messages coalesce while terminal states, logs and cleanup remain immediate", async ({ page }) => {
await page.route("**/performance-probe", (route) => route.fulfill({ contentType: "text/html", body: "<title>Probe</title>" }));
await page.goto("/performance-probe");
const clockStart = new Date("2026-09-10T00:00:00Z");
await page.clock.install({ time: clockStart });
await page.clock.pauseAt(new Date(clockStart.getTime() + 1000));
await page.evaluate(async () => {
// @ts-expect-error Vite serves this browser-side TypeScript module.
const { createSimulationConsoleStore } = await import("/src/simulationConsoleStore.ts");
const initial = { state: "running", percent: 0, message: "积分中", startTime: 0, simulatedTime: 0, totalTime: 10 };
const store = createSimulationConsoleStore(initial);
const probe = { store, initial, notifications: 0 };
store.subscribe(() => { probe.notifications += 1; });
(window as any).__storeProbe = probe;
for (let i = 1; i <= 100_000; i++) store.queueProgress({ ...initial, percent: i / 1000, simulatedTime: i / 10000 });
});
expect(await page.evaluate(() => (window as any).__storeProbe.notifications)).toBe(0);
await page.clock.runFor(249);
expect(await page.evaluate(() => (window as any).__storeProbe.notifications)).toBe(0);
await page.clock.runFor(1);
expect(await page.evaluate(() => {
const p = (window as any).__storeProbe;
return [p.notifications, p.store.getSnapshot().progress.percent];
})).toEqual([1, 100]);
for (let i = 1; i <= 4; i++) {
await page.evaluate((value) => {
const p = (window as any).__storeProbe;
for (let j = 0; j < 1000; j++) p.store.queueProgress({ ...p.initial, percent: value });
}, i);
await page.clock.runFor(250);
}
expect(await page.evaluate(() => (window as any).__storeProbe.notifications)).toBe(5);
await page.evaluate(() => {
const p = (window as any).__storeProbe;
p.store.queueProgress({ ...p.initial, percent: 80 });
p.store.setProgress((current: any) => ({ ...current, message: "正在停止仿真" }));
});
await page.clock.runFor(1000);
expect(await page.evaluate(() => {
const p = (window as any).__storeProbe;
return [p.notifications, p.store.getSnapshot().progress.message, p.store.getSnapshot().progress.percent];
})).toEqual([6, "正在停止仿真", 80]);
for (const state of ["success", "warning", "error"]) {
await page.evaluate((state) => {
const p = (window as any).__storeProbe;
p.store.queueProgress({ ...p.initial, percent: 90 });
p.store.setProgress({ ...p.initial, state, message: state });
}, state);
await page.clock.runFor(500);
expect(await page.evaluate(() => (window as any).__storeProbe.store.getSnapshot().progress.state)).toBe(state);
}
const counts = await page.evaluate(() => {
const p = (window as any).__storeProbe;
for (let i = 0; i < 1000; i++) p.store.appendEntry({ id: i, timestamp: "", level: "info", message: String(i), format: "message" });
const ids = p.store.getSnapshot().entries.map((entry: any) => entry.id);
p.store.clearEntries();
p.store.queueProgress({ ...p.initial, percent: 1 });
p.store.cancelPendingProgress();
return { length: ids.length, first: ids[0], last: ids.at(-1), notifications: p.notifications };
});
expect(counts).toMatchObject({ length: 400, first: 600, last: 999 });
await page.clock.runFor(1000);
expect(await page.evaluate(() => (window as any).__storeProbe.notifications)).toBe(counts.notifications);
});
async function prepareStreamApp(page: Page, nodeCount = 4) {
await prepareApp(page);
// Instrument only the test-served module; production code has no render counters.
await page.route("**/src/App.tsx", async (route) => {
const response = await route.fetch();
const source = await response.text();
expect(source).toContain("function FlowWorkbench() {");
await route.fulfill({ response, body: source.replace("function FlowWorkbench() {", "function FlowWorkbench() { window.__workbenchRenders = (window.__workbenchRenders || 0) + 1;") });
});
await page.addInitScript(() => {
const nativeFetch = window.fetch.bind(window);
window.fetch = async (...args) => {
if (!String(args[0]).includes("/api/system-xml/simulate-stream")) return nativeFetch(...args);
const encoder = new TextEncoder();
return new Response(new ReadableStream({ start(controller) {
(window as any).__sendSimulation = (events: unknown[], close = false) => {
controller.enqueue(encoder.encode(events.map((event) => JSON.stringify(event)).join("\n") + "\n"));
if (close) controller.close();
};
(window as any).__simulationController = controller;
} }), { headers: { "Content-Type": "application/x-ndjson" } });
};
});
await page.goto("/");
const nodes = Array.from({ length: nodeCount }, (_, i) => ({
...wideProject.nodes[0], id: `generic_sensor_${i + 1}`,
position: { x: (i % 20) * 160, y: Math.floor(i / 20) * 150 },
data: { ...wideProject.nodes[0].data, label: `sensor_${i + 1}` },
}));
const project = { ...wideProject, nodes, edges: nodes.map((node, i) => ({
id: `edge_${i}`, source: node.id, target: nodes[(i + 1) % nodes.length].id,
sourceHandle: "port_b", targetHandle: "port_a", data: { isContactEdge: false },
})) };
await page.locator('input[type="file"]').setInputFiles({ name: "stress.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(project)) });
await expect(page.locator(".react-flow__node")).toHaveCount(nodeCount);
// Let import/layout and the 600 ms autosave settle before counting simulation-only renders.
await expect(page.getByText(/已自动保存/).first()).toBeVisible();
await page.getByRole("button", { name: "运行仿真", exact: true }).click();
await expect.poll(() => page.evaluate(() => typeof (window as any).__sendSimulation)).toBe("function");
}
test("300-node workbench does not rerender for a 10,000-event progress storm", async ({ page }) => {
test.setTimeout(60_000);
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await prepareStreamApp(page, 300);
await page.evaluate(() => (window as any).__sendSimulation([{ event: "progress", phase: "integrating", progress: 1, simulatedTime: 0.1, totalTime: 10, message: "正在积分" }]));
const consolePanel = page.getByRole("complementary", { name: "仿真控制台", exact: true });
await expect(consolePanel).toContainText("正在积分");
const before = await page.evaluate(() => (window as any).__workbenchRenders);
const started = Date.now();
for (let burst = 0; burst < 20; burst++) {
await page.evaluate((burst) => {
const events = Array.from({ length: 500 }, (_, i) => ({ event: "progress", phase: "integrating", progress: 1 + (burst * 500 + i) / 125, simulatedTime: 0.1 + (burst * 500 + i) / 1250, totalTime: 10, message: "正在积分", activitySequence: burst * 500 + i + 1 }));
(window as any).__sendSimulation(events);
return new Promise((resolve) => setTimeout(resolve, 25));
}, burst);
}
await expect(consolePanel).toContainText("81%");
const after = await page.evaluate(() => (window as any).__workbenchRenders);
expect(after).toBe(before);
await expect(consolePanel).toHaveClass(/minimized/);
await page.evaluate((result) => (window as any).__sendSimulation([{ event: "result", result }], true), resultSnapshot.result);
await expect(page.getByRole("button", { name: "运行仿真", exact: true })).toBeEnabled();
await expect(consolePanel).toContainText("仿真完成");
await expect(consolePanel).toHaveClass(/minimized/);
expect(errors).toEqual([]);
console.log(JSON.stringify({ probe: "progress-storm", nodes: 300, events: 10000, workbenchRenders: after - before, elapsedMs: Date.now() - started }));
});
test("stop and stream errors cannot be overwritten by delayed running progress", async ({ page }) => {
await prepareStreamApp(page);
let cancellationRequests = 0;
await page.route("**/api/system-xml/simulations/*/cancel", (route) => {
cancellationRequests++;
return route.fulfill({ json: { accepted: true, status: "running" } });
});
await page.evaluate(() => (window as any).__sendSimulation([
{ event: "progress", phase: "integrating", progress: 1, message: "正在积分", simulatedTime: 0.1, totalTime: 10 },
{ event: "progress", phase: "integrating", progress: 40, message: "正在积分", simulatedTime: 4, totalTime: 10 },
]));
await page.getByRole("button", { name: "停止仿真", exact: true }).click();
await expect.poll(() => cancellationRequests).toBe(1);
await page.evaluate((result) => (window as any).__sendSimulation([{ event: "result", result: { ...result, status: "stopped", success: false, partial: true, simulatedUntil: 4 } }], true), resultSnapshot.result);
const panel = page.getByRole("complementary", { name: "仿真控制台", exact: true });
await expect(panel).toContainText("已手动终止");
await page.clock.install();
await page.clock.runFor(1000);
await expect(panel).toContainText("已手动终止");
await page.getByRole("button", { name: "运行仿真", exact: true }).click();
await page.evaluate(() => (window as any).__sendSimulation([
{ event: "progress", phase: "integrating", progress: 5, message: "运行中", simulatedTime: 0.5, totalTime: 10 },
{ event: "progress", phase: "integrating", progress: 7, message: "运行中", simulatedTime: 0.7, totalTime: 10 },
{ event: "error", message: "压力测试错误", status: 422 },
], true));
await page.clock.runFor(1000);
await expect(panel).toContainText("压力测试错误");
await expect(page.getByRole("button", { name: "运行仿真", exact: true })).toBeEnabled();
});
test("browser benchmark compares old and new decoders on the same 18 MiB result", async ({ page }) => {
await page.route("**/performance-probe", (route) => route.fulfill({ contentType: "text/html", body: "<title>Probe</title>" }));
await page.goto("/performance-probe");
const measurement = await page.evaluate(async () => {
// @ts-expect-error Vite serves this browser-side TypeScript module.
const { createNdjsonLineDecoder } = await import("/src/ndjsonStream.ts");
const values = Array.from({ length: 1_000_000 }, (_, i) => Math.sin(i * 0.013) * 1e5);
const bytes = new TextEncoder().encode(JSON.stringify({ event: "result", values }) + "\n");
const chunks: Uint8Array[] = [];
for (let i = 0; i < bytes.length; i += 65536) chunks.push(bytes.subarray(i, i + 65536));
const timings: Array<{ oldMs: number; newMs: number }> = [];
let correct = true;
for (let run = 0; run < 3; run++) {
const oldDecoder = new TextDecoder();
let buffer = "";
let oldValues: number[] = [];
let started = performance.now();
for (const chunk of chunks) {
buffer += oldDecoder.decode(chunk, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex >= 0) {
oldValues = JSON.parse(buffer.slice(0, newlineIndex).trim()).values;
buffer = buffer.slice(newlineIndex + 1);
newlineIndex = buffer.indexOf("\n");
}
}
const oldMs = performance.now() - started;
let newValues: number[] = [];
started = performance.now();
const decoder = createNdjsonLineDecoder((line: string) => { newValues = JSON.parse(line.trim()).values; });
chunks.forEach((chunk) => decoder.write(chunk));
decoder.finish();
const newMs = performance.now() - started;
correct &&= oldValues.length === values.length && newValues.length === values.length &&
newValues.every((value, i) => value === values[i] && value === oldValues[i]);
timings.push({ oldMs, newMs });
}
return { bytes: bytes.length, chunks: chunks.length, correct, timings };
});
expect(measurement.correct).toBe(true);
// Record timings instead of a hardware-dependent millisecond assertion.
console.log(JSON.stringify({ probe: "browser-ndjson-comparison", ...measurement }));
});
test("full app publishes a million-value streamed result without opening charts", async ({ page }) => {
await prepareStreamApp(page);
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
const measurement = await page.evaluate((template) => {
const points = 10000;
const series: Record<string, number[]> = { time: Array.from({ length: points }, (_, i) => i * 10 / (points - 1)) };
for (let v = 0; v < 100; v++) series[`generic_sensor_1.value_${v}`] = Array.from({ length: points }, (_, i) => Math.sin(i * 0.013 + v) * 1e5);
const result = { ...template, diagnostics: { ...template.diagnostics, sampleCount: points }, series };
const bytes = new TextEncoder().encode(JSON.stringify({ event: "result", result }) + "\n");
(window as any).__resultStressStarted = performance.now();
for (let i = 0; i < bytes.length; i += 65536) (window as any).__simulationController.enqueue(bytes.subarray(i, i + 65536));
(window as any).__simulationController.close();
return { bytes: bytes.length, points, variables: 100 };
}, resultSnapshot.result);
await expect(page.getByRole("button", { name: "运行仿真", exact: true })).toBeEnabled();
await expect(page.getByRole("complementary", { name: "仿真控制台", exact: true })).toContainText("10000 个采样点");
expect(errors).toEqual([]);
const elapsedMs = await page.evaluate(() => performance.now() - (window as any).__resultStressStarted);
console.log(JSON.stringify({ probe: "large-result-publication", ...measurement, elapsedMs }));
});
test("activity watchdog still sees all messages and recovers a genuinely stalled run", async ({ page }) => {
await prepareStreamApp(page);
const clockStart = new Date("2026-09-10T00:00:00Z");
await page.clock.install({ time: clockStart });
await page.clock.pauseAt(new Date(clockStart.getTime() + 1000));
let cancellationRequests = 0;
await page.route("**/api/system-xml/simulations/*/cancel", (route) => {
cancellationRequests++;
return route.fulfill({ json: { accepted: true, status: "running" } });
});
await page.route(/\/api\/system-xml\/simulations\/[^/]+$/, (route) => route.fulfill({ json: {
status: "stalled", result: { ...resultSnapshot.result, status: "stalled", success: false, simulatedUntil: 5 },
} }));
const sendHeartbeat = (sequence: number) => page.evaluate((sequence) => (window as any).__sendSimulation([{
event: "progress", phase: "integrating", progress: 50, simulatedTime: 5, totalTime: 10,
message: "正在积分", heartbeat: true, activitySequence: sequence, acceptedStepSequence: 10, acceptedTime: 5,
}]), sequence);
await sendHeartbeat(1);
for (let sequence = 2; sequence <= 6; sequence++) {
await page.clock.runFor(20000);
await sendHeartbeat(sequence);
}
expect(cancellationRequests).toBe(0);
await expect(page.getByRole("button", { name: "运行仿真", exact: true })).toBeDisabled();
const panel = page.getByRole("complementary", { name: "仿真控制台", exact: true });
await expect(panel).toContainText("内部计算仍活跃");
for (let i = 0; i < 3; i++) {
await page.clock.runFor(20000);
await sendHeartbeat(6);
}
await expect.poll(() => cancellationRequests).toBe(1);
await expect(panel).toContainText("异常而终止");
await expect(page.getByRole("button", { name: "运行仿真", exact: true })).toBeEnabled();
await page.clock.runFor(1000);
await expect(panel).toContainText("异常而终止");
});