Files
SystemSimulationApp/frontend/public/demos/amesim-submodels/app.js
T

257 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
'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'));
})();