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

314 lines
25 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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'));
})();