(() => {
'use strict';
const KEY = 'system-simulation:pipe-submodels-layout-demo:v1';
const $ = id => document.getElementById(id);
const safe = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 ``;
}
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 ``;
}).join('');
$('library-hint').textContent = editable ? `${pipe.id}:${candidates(pipe).length} 个兼容型号。点击预览,应用后生效。` : '切换到“子模型”模式可更换管路型号。';
$('pipe-list').innerHTML = PIPES.map(item => ``).join('');
}
function drawFixedComponents() {
const port = (x, y, label = '') => `${label ? `${label}` : ''}`;
$('component-layer').innerHTML = `
${port(35, 0)}储气罐 APNCH023
${port(-20, 0, '1')}${port(20, 0, '2')}${port(0, -20, '3')}${port(0, 20, '4')}节点 N1P4NODE2 · 固定
${port(-35, 0)}阀门 BPNVO001
${port(0, 4)}封堵 C
${port(0, -4)}封堵 D`;
}
function renderWires() {
$('wire-layer').innerHTML = PIPES.map(pipe => ``).join('');
const compact = $('canvas').clientWidth < 460;
$('pipe-label-layer').innerHTML = PIPES.map(pipe => `${pipe.id}${!compact && $('show-labels').checked && state.mode !== 'modeling' ? ' · ' + (state.configs[pipe.id].model || '未指定') : ''}`).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 = `
| 对象 | 管路 ${pipe.id} |
| 连接端 A | ${safe(endpoints[pipe.a].label)} |
| 连接端 B | ${safe(endpoints[pipe.b].label)} |
| 已用型号 | ${config.model || '未指定'} |
${model ? `${pipeIcon(model)}
${model.id}${model.name}${editable && model.id !== config.model ? ' · 待应用' : ''}
${model.description}
${editable ? `
` : ''}
端口方向与选型说明
${safe(portMapping(pipe, model))}
按接口筛选;储气、压降等物理作用由所选型号决定。
C 表示储气,R 表示阻力。图标沿用建模区表示方式。
` : '在“子模型”模式从左侧选择管路型号。
'}`;
renderParameters();
}
function renderParameters() {
const config = activeConfig(), model = modelById(config.model), editable = state.mode === 'parameters';
$('edit-parameters').disabled = !model || editable;
if (!model) { $('parameter-properties').innerHTML = '应用管路子模型后显示所需参数。
'; return; }
$('parameter-properties').innerHTML = ``;
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 => `${item.kind}${item.pipe ? `` : `${safe(item.text)}`}
`).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'));
})();