Files
invyone/frontend/public/scada-demo/js/topology.js
T
gbpark 7635412b7b feat: SCADA 시연용 모바일 알람 동반 화면 + Spring WebSocket
- 작업자 폰(/mobile)을 SCADA 데모와 ws 로 연결, 알람 발생 시 풀스크린 푸시
  · v5 솔리드+글로우 톤, 진동/Web Audio 비프/Wake Lock/auto reconnect
  · 시연 안전망: ?test=1 자동 발동, 우상단 hidden 트리거
- backend: com.erp.alarm 신규 패키지 (WebSocketConfig + Handshake + Handler + Controller)
  · JWT 토큰 핸드셰이크 검증, userId 기반 채널 매핑 (멀티 디바이스 지원)
  · spring-boot-starter-websocket 의존성 추가
  · path 를 /api/demo/* 안에 두어 Traefik 라우트 추가 불필요 + 정식 알람과 분리
- SCADA scenario.js 의 emergency 시퀀스(2700ms)에 fetch('/api/demo/alarm/trigger') 배선
  · /scada?worker=<user_id> query 로 target user 지정 (iframe src 로 전달)
- 운영 시연 URL: siflex.invyone.com/mobile (siflex_user) ↔ /scada?worker=siflex_user

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:18:12 +09:00

611 lines
35 KiB
JavaScript

// INVYONE Stage-2 Water Plant Topology
// Port-based connection model: pipes reference `componentId.portName` instead of raw coords.
// Canvas: 2000 x 880 SVG.
(function (global) {
// ===== TANKS =====
const TANKS = [
{ id: 'sand', x: 340, y: 120, w: 96, h: 150, type: 'ac', label: 'SAND TANK', capacity: 5000, level: 62.0, color: '#5af9ff', alarms: { hh: 95, hl: 85, ll: 15, lh: 5 } },
{ id: 'ac', x: 500, y: 120, w: 96, h: 150, type: 'ac', label: 'AC TANK', capacity: 5000, level: 58.4, color: '#5af9ff', alarms: { hh: 95, hl: 85, ll: 15, lh: 5 } },
{ id: 'raw', x: 660, y: 90, w: 120, h: 230, type: 'tank', label: 'RAW WATER TANK', capacity: 30000, level: 75.0, color: '#3aa3ff', alarms: { hh: 95, hl: 85, ll: 20, lh: 10 } },
{ id: 'chem', x: 895, y: 110, w: 50, h: 70, type: 'dosing', label: 'Chem', capacity: 500, level: 71.0, color: '#7cff3a', alarms: { hh: 95, hl: 85, ll: 15, lh: 5 } },
{ id: 'water-filter', x: 1600, y: 90, w: 120, h: 230, type: 'tank', label: 'WATER FILTER TANK', capacity: 25000, level: 48.0, color: '#3aa3ff', alarms: { hh: 95, hl: 85, ll: 20, lh: 10 }, nozzles: [{ side: 'left', pos: 0.30 }, { side: 'bot', pos: 0.5 }] },
{ id: 'chem-cep-1', x: 80, y: 545, w: 50, h: 70, type: 'dosing', label: 'CEP-1', capacity: 500, level: 64.2, color: '#ff8a3a', alarms: { hh: 95, hl: 85, ll: 15, lh: 5 } },
{ id: 'chem-cep-2', x: 150, y: 545, w: 50, h: 70, type: 'dosing', label: 'CEP-2', capacity: 500, level: 55.8, color: '#ff4f9a', alarms: { hh: 95, hl: 85, ll: 15, lh: 5 } },
{ id: 'cip-tank', x: 80, y: 765, w: 60, h: 80, type: 'dosing', label: 'CIP TANK', capacity: 1500, level: 80.5, color: '#5af9ff', alarms: { hh: 95, hl: 85, ll: 15, lh: 5 } },
{ id: 'mbr', x: 760, y: 600, w: 500, h: 200, type: 'mbr', label: 'MBR TANK', capacity: 40000, level: 52.0, color: '#ffd682', alarms: { hh: 92, hl: 80, ll: 20, lh: 10 } },
{ id: 'dp', x: 1320, y: 545, w: 90, h: 180, type: 'tank', label: 'DP TANK', capacity: 8000, level: 87.0, color: '#3aa3ff', alarms: { hh: 95, hl: 85, ll: 20, lh: 10 } },
{ id: 'regulating', x: 1580, y: 640, w: 100, h: 180, type: 'tank', label: 'REGULATING TANK', capacity: 12000, level: 54.06, color: '#ff8a3a', alarms: { hh: 95, hl: 85, ll: 20, lh: 10 } },
];
const FILTERS = [
{ id: 'pump-filter', anchor: { to: 'bw-a1.out', myPort: 'left', dx: 107 }, label: 'PUMP FILTER' },
{ id: 'uf-filter', anchor: { to: 'uf-pump-a.out',myPort: 'left', dx: 50 }, label: 'FILTER' },
{ id: 'ro-filter', anchor: { to: 'ro-feed.out', myPort: 'left', dx: 50 }, label: 'FILTER' },
{ id: 'cip-filter', anchor: { to: 'cip-tank.right', myPort: 'left', dx: 35, dy: 1 }, label: 'FILTER' },
];
const MODULES = [
{ id: 'uf-system', anchor: { to: 'uf-filter.right', myPort: 'topLeft', dx: 62, dy: -122 }, type: 'membrane', count: 5, label: 'UF SYSTEM', accent: '#5af9ff', orientation: 'vertical' },
{ id: 'ro-system', anchor: { to: 'ro-press.out', myPort: 'in', dx: 55, dy: -47 }, type: 'membrane', count: 5, label: 'RO SYSTEM', accent: '#5af9ff', orientation: 'horizontal' },
{ id: 'mbr-carb-l', anchor: { to: 'mbr.top', myPort: 'topLeft', dx: -183, dy: 106 }, type: 'carbon', count: 5, label: 'CARBON L', accent: '#5af9ff' },
{ id: 'mbr-carb-r', anchor: { to: 'mbr.top', myPort: 'topLeft', dx: 77, dy: 106 }, type: 'carbon', count: 5, label: 'CARBON R', accent: '#5af9ff' },
];
const PUMPS = [
// Root pumps (no natural upstream tank — keep absolute)
{ id: 'bw-a1', x: 78, y: 220, label: 'FEED-A', source: null, dest: 'sand', lpm: 80, valves: ['v-input'], pipes: ['p-input-bwa1', 'p-bwa1-pf', 'p-pf-sand'] },
{ id: 'air-blower', x: 380, y: 785, label: 'AIR BLOWER', source: null, dest: 'mbr', lpm: 0, valves: [], pipes: ['p-blower-mbr'] },
// Tank-foot pumps (anchor to tank bottom, my top inlet aligns)
{ id: 'sand-out', anchor: { to: 'sand.bot', myPort: 'in', dy: 14 }, label: 'SAND PUMP', source: 'sand', dest: 'ac', lpm: 80, valves: ['v-sand-out'], pipes: ['p-sand-down', 'p-sand-ac'], orient: 'v' },
{ id: 'ac-out', anchor: { to: 'ac.bot', myPort: 'in', dy: 14 }, label: 'AC PUMP', source: 'ac', dest: 'raw', lpm: 80, valves: ['v-ac-out'], pipes: ['p-ac-down', 'p-ac-raw'], orient: 'v' },
{ id: 'chem-uf', anchor: { to: 'chem.bot', myPort: 'in', dy: 90 }, label: 'CHEM PUMP', source: 'chem', dest: 'water-filter', lpm: 8, valves: ['v-chem-uf'], pipes: ['p-chem-uf', 'p-chemuf-out'], orient: 'v' },
// Process pumps (anchor to upstream tank or peer pump)
{ id: 'bw-a2', anchor: { to: 'raw.right', myPort: 'in', dx: 50, dy: 45 }, label: 'FEED-B', source: 'raw', dest: 'water-filter', lpm: 120, valves: ['v-raw-out'], pipes: ['p-raw-bw2', 'p-bw2-uf'] },
{ id: 'uf-pump-a', anchor: { to: 'bw-a2.out', myPort: 'in', dx: 120 }, label: 'UF PUMP-A', source: 'raw', dest: 'water-filter', lpm: 200, valves: ['v-uf-in','v-uf-out'], pipes: ['p-raw-uf', 'p-uf-filter', 'p-filter-uf', 'p-uf-wf'] },
{ id: 'uf-pump-b', anchor: { to: 'uf-pump-a.bot', myPort: 'top', dy: 12 }, label: 'UF PUMP-B', source: 'raw', dest: 'water-filter', lpm: 200, valves: ['v-uf-in','v-uf-out'], pipes: ['p-raw-uf', 'p-ufpb-filter', 'p-filter-uf', 'p-uf-wf'] },
// CEP/CIP pumps — root for that area (no natural anchor target before them)
{ id: 'ro-feed', x: 210, y: 630, label: 'RO FEED', source: 'water-filter', dest: 'mbr', lpm: 150, valves: ['v-ro-in'], pipes: ['p-wf-ro', 'p-rofeed-filter'] },
{ id: 'ro-press', anchor: { to: 'ro-filter.right', myPort: 'in', dx: 13 }, label: 'RO PRESS', source: 'water-filter', dest: 'mbr', lpm: 150, valves: ['v-ro-press-out'], pipes: ['p-filter-ropress', 'p-ropress-ro', 'p-ro-mbr'] },
{ id: 'cip-pump', anchor: { to: 'cip-filter.right', myPort: 'in', dx: 60, dy: 14 }, label: 'CIP PUMP', source: 'cip-tank', dest: 'ro-system', lpm: 60, valves: ['v-cip-out'], pipes: ['p-ciptank-pump', 'p-cip-ro'] },
// Downstream pumps
{ id: 'aer-drain', anchor: { to: 'mbr.botRight', myPort: 'in', dx: -152, dy: 0 }, label: 'AERATION DRAIN', source: 'mbr', dest: 'dp', lpm: 100, valves: ['v-mbr-out'], pipes: ['p-mbr-aer', 'p-aer-dp'] },
{ id: 'dp-out', anchor: { to: 'dp.right', myPort: 'in', dx: 80, dy: 155 }, label: 'DP PUMP', source: 'dp', dest: 'regulating', lpm: 100, valves: ['v-dp-out'], pipes: ['p-dp-down', 'p-dp-pump-reg'] },
];
const VALVES = [
// All valves placed on their gating pipe — onPipe + at fraction
{ id: 'v-input', anchor: { onPipe: 'p-bwa1-pf', at: 0.30 }, label: 'V-IN', open: false },
{ id: 'v-sand-out', anchor: { onPipe: 'p-sand-ac', at: 0.55 }, label: '', open: false },
{ id: 'v-ac-out', anchor: { onPipe: 'p-ac-raw', at: 0.78 }, label: '', open: false },
{ id: 'v-raw-out', anchor: { onPipe: 'p-raw-bw2', at: 0.78 }, label: '', open: false },
{ id: 'v-chem-uf', anchor: { onPipe: 'p-chem-uf', at: 0.5 }, label: '', open: false },
{ id: 'v-uf-in', anchor: { onPipe: 'p-bw2-uf', at: 0.75 }, label: 'V-UF-IN', open: false },
{ id: 'v-uf-out', anchor: { onPipe: 'p-uf-wf', at: 0.20 }, label: 'V-UF-OUT', open: false },
{ id: 'v-ro-in', anchor: { onPipe: 'p-rofeed-filter', at: 0.5 }, label: '', open: false },
{ id: 'v-ro-press-out', anchor: { onPipe: 'p-ropress-ro', at: 0.4 }, label: '', open: false },
{ id: 'v-cip-out', anchor: { onPipe: 'p-cip-ro', at: 0.4 }, label: '', open: false },
{ id: 'v-mbr-out', anchor: { onPipe: 'p-mbr-aer', at: 0.4 }, label: '', open: false },
{ id: 'v-dp-out', anchor: { onPipe: 'p-dp-down', at: 0.5 }, label: '', open: false },
];
// ===== ANCHORS =====
// Components can declare `anchor: { to: 'parent.port', myPort: 'in', dx?, dy? }`
// Position is derived from parent — no hardcoded x/y needed for anchored components.
function getPortAbsPos(comp, portName) {
// Resolves a single port's absolute coords given a component def with x/y/w/h.
const { x, y, w, h, type } = comp;
const cw = w || 60, ch = h || 60;
if (type === 'ac') {
if (portName === 'bot') return { x: x + cw / 2, y: y + ch + 6 };
if (portName === 'top') return { x: x + cw / 2, y };
if (portName === 'left') return { x: x, y: y + ch / 2 + 5 };
if (portName === 'right') return { x: x + cw, y: y + ch / 2 + 5 };
} else if (type === 'dosing') {
if (portName === 'bot') return { x: x + cw / 2, y: y + ch };
if (portName === 'top') return { x: x + cw / 2, y };
if (portName === 'left') return { x: x, y: y + ch / 2 };
if (portName === 'right') return { x: x + cw, y: y + ch / 2 };
} else if (type === 'mbr' || type === 'tank') {
if (portName === 'bot') return { x: x + cw / 2, y: y + ch };
if (portName === 'top') return { x: x + cw / 2, y };
if (portName === 'botLeft') return { x: x + 12, y: y + ch };
if (portName === 'botRight') return { x: x + cw - 12,y: y + ch };
if (portName === 'left') return { x: x, y: y + ch / 2 };
if (portName === 'right') return { x: x + cw, y: y + ch / 2 };
} else if (type === 'pump') {
// Horizontal pump
if (portName === 'in') return { x, y: y + 30 };
if (portName === 'out') return { x: x + 60, y: y + 30 };
if (portName === 'top') return { x: x + 30, y };
if (portName === 'bot') return { x: x + 30, y: y + 60 };
} else if (type === 'pumpV') {
if (portName === 'in') return { x: x + 30, y };
if (portName === 'out') return { x: x + 60, y: y + 47 };
if (portName === 'top') return { x: x + 30, y };
if (portName === 'bot') return { x: x + 30, y: y + 70 };
} else if (type === 'airBlower') {
if (portName === 'in') return { x, y: y + 25 };
if (portName === 'out') return { x: x + 80, y: y + 25 };
if (portName === 'top') return { x: x + 22, y };
} else if (type === 'valve') {
// valve's own width is 50, body center at (25, 26)
if (portName === 'left') return { x, y: y + 26 };
if (portName === 'right') return { x: x + 50, y: y + 26 };
if (portName === 'center') return { x: x + 25, y: y + 26 };
if (portName === 'top') return { x: x + 25, y };
} else if (type === 'filter') {
if (portName === 'left') return { x, y: y + 41 };
if (portName === 'right') return { x: x + 32, y: y + 41 };
if (portName === 'top') return { x: x + 16, y };
if (portName === 'bot') return { x: x + 16, y: y + 82 };
if (portName === 'center') return { x: x + 16, y: y + 41 };
} else if (type === 'gauge') {
if (portName === 'center') return { x: x + 40, y: y + 40 };
if (portName === 'top') return { x: x + 40, y };
if (portName === 'bot') return { x: x + 40, y: y + 75 };
} else if (type === 'sensor') {
if (portName === 'topLeft') return { x, y };
if (portName === 'left') return { x, y: y + 18 };
if (portName === 'right') return { x: x + 110, y: y + 18 };
if (portName === 'top') return { x: x + 55, y };
if (portName === 'center') return { x: x + 55, y: y + 18 };
} else if (type === 'led') {
if (portName === 'center') return { x: x + 7, y: y + 7 };
} else if (type === 'membraneV') {
// vertical UF: w computed elsewhere; use defaults if no w
const w = comp.w || 174;
const h = 156;
if (portName === 'topLeft') return { x: x + 4, y: y + 6 };
if (portName === 'topRight') return { x: x + w - 4, y: y + 6 };
if (portName === 'left') return { x: x, y: y + h / 2 };
if (portName === 'right') return { x: x + w, y: y + h / 2 };
} else if (type === 'membraneH') {
const w = comp.w || 208;
const h = comp.h || 136;
if (portName === 'in') return { x, y: y + h / 2 };
if (portName === 'out') return { x: x + w, y: y + h / 2 };
if (portName === 'top') return { x: x + w / 2, y };
if (portName === 'bot') return { x: x + w / 2, y: y + h };
} else if (type === 'carbon') {
const w = comp.w || 86;
if (portName === 'topLeft') return { x: x + 2, y: y + 16 };
if (portName === 'topRight') return { x: x + w - 2, y: y + 16 };
}
return null;
}
// Tag effective type on every component (so getPortAbsPos can look up).
function tagTypes() {
TANKS.forEach(t => { t._type = t.type; });
PUMPS.forEach(p => {
p._type = p.id === 'air-blower' ? 'airBlower' : (p.orient === 'v' ? 'pumpV' : 'pump');
});
VALVES.forEach(v => { v._type = 'valve'; });
FILTERS.forEach(f => { f._type = 'filter'; });
GAUGES.forEach(g => { g._type = 'gauge'; });
SENSORS.forEach(s => { s._type = 'sensor'; });
LEDS.forEach(l => { l._type = 'led'; });
MODULES.forEach(m => {
m._type = m.type === 'carbon' ? 'carbon' : (m.orientation === 'vertical' ? 'membraneV' : 'membraneH');
});
}
// Walk an orthogonal pipe's points and find absolute (x,y) at a given fraction (0..1) of total length.
function pipePointAt(points, fraction) {
fraction = Math.max(0, Math.min(1, fraction || 0));
let total = 0;
for (let i = 1; i < points.length; i++) {
total += Math.abs(points[i][0] - points[i - 1][0]) + Math.abs(points[i][1] - points[i - 1][1]);
}
const target = total * fraction;
let acc = 0;
for (let i = 1; i < points.length; i++) {
const dx = points[i][0] - points[i - 1][0];
const dy = points[i][1] - points[i - 1][1];
const len = Math.abs(dx) + Math.abs(dy);
if (acc + len >= target) {
const t = len === 0 ? 0 : (target - acc) / len;
return {
x: points[i - 1][0] + dx * t,
y: points[i - 1][1] + dy * t,
horizontal: dx !== 0,
};
}
acc += len;
}
return { x: points[points.length - 1][0], y: points[points.length - 1][1], horizontal: true };
}
// ===== ANCHOR RESOLUTION (multi-pass) =====
function resolveAnchors() {
tagTypes();
const allComps = [...TANKS, ...PUMPS, ...VALVES, ...FILTERS, ...MODULES, ...GAUGES, ...SENSORS, ...LEDS];
// Tanks/modules/panels with explicit x,y are pre-resolved.
allComps.forEach(c => { if (typeof c.x === 'number' && typeof c.y === 'number' && !c.anchor) c._resolved = true; });
let pass = 0, changed = true;
while (changed && pass++ < 12) {
changed = false;
allComps.forEach(c => {
if (c._resolved) return;
if (!c.anchor) return;
// onPipe handled in second phase
if (c.anchor.onPipe) return;
if (c.anchor.absolute) {
c.x = c.anchor.absolute.x;
c.y = c.anchor.absolute.y;
c._resolved = true; changed = true; return;
}
if (!c.anchor.to) return;
const [refId, refPort] = c.anchor.to.split('.');
const refComp = allComps.find(x => x.id === refId);
if (!refComp) { console.warn('[INVYONE] anchor ref missing:', c.anchor.to, 'for', c.id); return; }
if (!refComp._resolved) return; // wait
const refPos = getPortAbsPos({ ...refComp, type: refComp._type }, refPort);
if (!refPos) { console.warn('[INVYONE] port not found:', c.anchor.to); return; }
const myLocal = getPortAbsPos({ x: 0, y: 0, type: c._type, w: c.w, h: c.h }, c.anchor.myPort || 'left') || { x: 0, y: 0 };
c.x = Math.round(refPos.x - myLocal.x + (c.anchor.dx || 0));
c.y = Math.round(refPos.y - myLocal.y + (c.anchor.dy || 0));
c._resolved = true;
changed = true;
});
}
}
function resolveOnPipeAnchors(resolvedPipes) {
const allComps = [...VALVES, ...FILTERS, ...GAUGES, ...SENSORS, ...LEDS];
allComps.forEach(c => {
if (c._resolved) return;
if (!c.anchor || !c.anchor.onPipe) return;
const pipeRes = resolvedPipes.find(p => p.spec.id === c.anchor.onPipe);
if (!pipeRes) { console.warn('[INVYONE] onPipe ref missing:', c.anchor.onPipe); return; }
const pt = pipePointAt(pipeRes.points, c.anchor.at != null ? c.anchor.at : 0.5);
const myLocal = getPortAbsPos({ x: 0, y: 0, type: c._type, w: c.w, h: c.h }, c.anchor.myPort || 'center') || { x: 0, y: 0 };
c.x = Math.round(pt.x - myLocal.x + (c.anchor.dx || 0));
c.y = Math.round(pt.y - myLocal.y + (c.anchor.dy || 0));
c._orientation = pt.horizontal ? 'h' : 'v';
c._resolved = true;
});
}
// ===== PORTS =====
// Each component exposes named connection points. Pipes reference them as 'id.port'.
function computePorts() {
const ports = {};
TANKS.forEach(t => {
const { id, x, y, w, h, type } = t;
if (type === 'ac') {
ports[id] = {
top: { x: x + w / 2, y: y },
left: { x: x, y: y + h / 2 + 5 },
right: { x: x + w, y: y + h / 2 + 5 },
bot: { x: x + w / 2, y: y + h + 6 },
};
} else if (type === 'dosing') {
ports[id] = {
top: { x: x + w / 2, y: y },
bot: { x: x + w / 2, y: y + h },
left: { x: x, y: y + h / 2 },
right: { x: x + w, y: y + h / 2 },
};
} else if (type === 'mbr') {
ports[id] = {
top: { x: x + w / 2, y: y },
bot: { x: x + w / 2, y: y + h },
botLeft: { x: x + 8, y: y + h },
botRight: { x: x + w - 8, y: y + h },
left: { x: x, y: y + h / 2 },
right: { x: x + w, y: y + h / 2 },
rightHigh:{ x: x + w, y: y + h * 0.35 },
rightLow: { x: x + w, y: y + h * 0.85 },
leftHigh: { x: x, y: y + h * 0.35 },
leftLow: { x: x, y: y + h * 0.85 },
};
} else {
ports[id] = {
top: { x: x + w / 2, y: y },
bot: { x: x + w / 2, y: y + h },
botLeft: { x: x + 12, y: y + h },
botRight: { x: x + w - 12,y: y + h },
left: { x: x, y: y + h / 2 },
leftHigh: { x: x, y: y + h * 0.30 },
leftLow: { x: x, y: y + h * 0.85 },
right: { x: x + w, y: y + h / 2 },
rightHigh: { x: x + w, y: y + h * 0.30 },
rightLow: { x: x + w, y: y + h * 0.85 },
};
}
});
PUMPS.forEach(p => {
if (p.id === 'air-blower') {
ports[p.id] = {
in: { x: p.x, y: p.y + 25 },
out: { x: p.x + 80, y: p.y + 25 },
top: { x: p.x + 22, y: p.y },
};
} else if (p.orient === 'v') {
ports[p.id] = {
in: { x: p.x + 30, y: p.y }, // top inlet
out: { x: p.x + 60, y: p.y + 47 }, // right outlet (mid-housing)
top: { x: p.x + 30, y: p.y },
bot: { x: p.x + 30, y: p.y + 70 },
};
} else {
ports[p.id] = {
in: { x: p.x, y: p.y + 30 },
out: { x: p.x + 60, y: p.y + 30 },
top: { x: p.x + 30, y: p.y },
bot: { x: p.x + 30, y: p.y + 60 },
};
}
});
VALVES.forEach(v => {
ports[v.id] = {
left: { x: v.x + 4, y: v.y + 26 },
right: { x: v.x + 46, y: v.y + 26 },
};
});
FILTERS.forEach(f => {
ports[f.id] = {
left: { x: f.x, y: f.y + 41 },
right: { x: f.x + 32, y: f.y + 41 },
top: { x: f.x + 16, y: f.y },
bot: { x: f.x + 16, y: f.y + 82 },
};
});
MODULES.forEach(m => {
const count = m.count || 5;
if (m.type === 'membrane' && m.orientation === 'vertical') {
const cellW = 22, gap = 10;
const w = count * cellW + (count - 1) * gap + 24;
const h = 130 + 26;
ports[m.id] = {
topLeft: { x: m.x + 4, y: m.y + 6 },
topRight: { x: m.x + w - 4, y: m.y + 6 },
topMid: { x: m.x + w / 2, y: m.y + 6 },
botLeft: { x: m.x + 4, y: m.y + h - 6 },
botRight: { x: m.x + w - 4, y: m.y + h - 6 },
left: { x: m.x, y: m.y + h / 2 },
right: { x: m.x + w, y: m.y + h / 2 },
};
} else if (m.type === 'membrane') {
const cellH = 16, gap = 8;
const w = 180 + 28;
const h = count * cellH + (count - 1) * gap + 24;
ports[m.id] = {
in: { x: m.x, y: m.y + h / 2 },
out: { x: m.x + w, y: m.y + h / 2 },
top: { x: m.x + w / 2, y: m.y },
bot: { x: m.x + w / 2, y: m.y + h },
};
} else if (m.type === 'carbon') {
const cellW = 14, gap = 5;
const w = count * cellW + (count - 1) * gap + 16;
ports[m.id] = {
topLeft: { x: m.x + 2, y: m.y + 16 },
topRight: { x: m.x + w - 2, y: m.y + 16 },
};
}
});
return ports;
}
// ===== PIPE RESOLVER =====
function resolveRef(ref, ports) {
if (typeof ref === 'object' && 'x' in ref && 'y' in ref) return ref;
const [id, portName] = String(ref).split('.');
const p = ports[id] && ports[id][portName];
if (!p) {
console.warn('[INVYONE] Port not found:', ref);
return { x: 0, y: 0 };
}
return p;
}
// Auto-route orthogonal between two points with optional waypoints.
// Each waypoint is { x?, y? } or a port ref string. Missing axis inherits from previous.
function autoRoute(from, to, via, hint, ports) {
const pts = [from];
let cur = from;
(via || []).forEach(wp => {
const w = typeof wp === 'string' ? resolveRef(wp, ports) : wp;
const next = {
x: ('x' in w) ? w.x : cur.x,
y: ('y' in w) ? w.y : cur.y,
};
if (next.x !== cur.x && next.y !== cur.y) {
pts.push({ x: cur.x, y: next.y }); // implicit corner
}
pts.push(next);
cur = next;
});
if (cur.x !== to.x && cur.y !== to.y) {
if (hint === 'v-first') pts.push({ x: cur.x, y: to.y });
else pts.push({ x: to.x, y: cur.y });
}
pts.push(to);
return pts.map(p => [p.x, p.y]);
}
function resolvePipe(spec, ports) {
if (Array.isArray(spec.points)) return spec.points;
const from = resolveRef(spec.from, ports);
const to = resolveRef(spec.to, ports);
return autoRoute(from, to, spec.via, spec.hint, ports);
}
// ===== PIPES (port-based) =====
// Each pipe: { id, from: 'comp.port', to: 'comp.port', via?: [...], hint?: 'h-first'|'v-first' }
const PIPES = [
// Top row — pre-treatment
{ id: 'p-input-bwa1', from: { x: 40, y: 250 }, to: 'bw-a1.in' },
{ id: 'p-bwa1-pf', from: 'bw-a1.out', to: 'pump-filter.left' },
{ id: 'p-pf-sand', from: 'pump-filter.right', to: 'sand.left', via: [{ y: 200 }] },
{ id: 'p-sand-down', from: 'sand.bot', to: 'sand-out.top' },
{ id: 'p-sand-ac', from: 'sand-out.out', to: 'ac-out.in', via: [{ y: 290 }] },
{ id: 'p-ac-down', from: 'ac.bot', to: 'ac-out.top' },
{ id: 'p-ac-raw', from: 'ac-out.out', to: 'raw.top', via: [{ x: 605 }, { y: 90 }] },
{ id: 'p-raw-bw2', from: 'raw.right', to: 'bw-a2.in', via: [{ y: 250 }] },
{ id: 'p-bw2-uf', from: 'bw-a2.out', to: 'uf-pump-a.in' },
{ id: 'p-chem-uf', from: 'chem.bot', to: 'chem-uf.in', hint: 'v-first' },
{ id: 'p-chemuf-out', from: 'chem-uf.out', to: { x: 970, y: 250 } },
{ id: 'p-raw-uf', from: 'raw.rightLow', to: 'uf-pump-b.in', via: [{ x: 990 }] },
{ id: 'p-uf-filter', from: 'uf-pump-a.out', to: 'uf-filter.left' },
{ id: 'p-ufpb-filter',from: 'uf-pump-b.out', to: 'uf-filter.left' },
{ id: 'p-filter-uf', from: 'uf-filter.right', to: 'uf-system.topLeft' },
{ id: 'p-uf-wf', from: 'uf-system.botRight', to: 'water-filter.leftHigh', via: [{ x: 1490 }, { y: 159 }] },
// Bottom row — RO / MBR / DP / REG
{ id: 'p-wf-ro', from: 'water-filter.bot', to: 'ro-feed.in', via: [{ y: 420 }, { x: 200 }, { y: 660 }] },
{ id: 'p-rofeed-filter', from: 'ro-feed.out', to: 'ro-filter.left' },
{ id: 'p-filter-ropress',from: 'ro-filter.right', to: 'ro-press.in' },
{ id: 'p-ropress-ro', from: 'ro-press.out', to: 'ro-system.in' },
{ id: 'p-ro-mbr', from: 'ro-system.out', to: 'mbr.leftHigh' },
{ id: 'p-cip-ro', from: 'cip-pump.out', to: 'ro-system.bot', via: [{ y: 730 }] },
{ id: 'p-ciptank-pump', from: 'cip-tank.bot', to: 'cip-pump.in', via: [{ y: 820 }] },
{ id: 'p-blower-mbr', from: 'air-blower.out', to: 'mbr.bot', via: [{ x: 700 }] },
// MBR → AERATION DRAIN → DP TANK
{ id: 'p-mbr-aer', from: 'mbr.botRight', to: 'aer-drain.in' },
{ id: 'p-aer-dp', from: 'aer-drain.out', to: 'dp.bot', via: [{ x: 1290 }, { y: 760 }, { x: 1365 }] },
// DP TANK → DP PUMP → REGULATING
{ id: 'p-dp-down', from: 'dp.bot', to: 'dp-out.in' },
{ id: 'p-dp-pump-reg',from: 'dp-out.out', to: 'regulating.left', via: [{ y: 730 }] },
];
const GAUGES = [
{ id: 'p-in-1', anchor: { to: 'bw-a1.top', myPort: 'center', dx: -23, dy: -110 }, label: 'P-IN', max: 8, unit: 'bar', source: 'bw-a1' },
{ id: 'p-in-2', anchor: { to: 'bw-a1.top', myPort: 'center', dx: 87, dy: -110 }, label: 'P-PF', max: 8, unit: 'bar', source: 'bw-a1' },
{ id: 'p-raw', anchor: { to: 'raw.bot', myPort: 'center', dx: -35, dy: 60 }, label: 'P-RAW', max: 8, unit: 'bar', source: 'bw-a2' },
{ id: 'p-uf-1', anchor: { to: 'uf-pump-a.bot', myPort: 'center', dx: 210, dy: 75 }, label: 'P-UF-IN', max: 10, unit: 'bar', source: 'uf-pump-a' },
{ id: 'p-uf-2', anchor: { to: 'uf-pump-a.bot', myPort: 'center', dx: 515, dy: 75 }, label: 'P-UF-OUT', max: 10, unit: 'bar', source: 'uf-pump-a' },
{ id: 'p-wf', anchor: { to: 'water-filter.right', myPort: 'center', dx: 90, dy: -45 }, label: 'P-WF', max: 8, unit: 'bar', source: 'bw-a2' },
{ id: 'p-cep-1', anchor: { to: 'chem-cep-1.bot', myPort: 'center', dx: -15, dy: 65 }, label: 'P1', max: 8, unit: 'bar', source: 'ro-feed' },
{ id: 'p-cep-2', anchor: { to: 'chem-cep-2.bot', myPort: 'center', dx: -5, dy: 65 }, label: 'P2', max: 8, unit: 'bar', source: 'ro-feed' },
{ id: 'p-ro-in', anchor: { to: 'ro-feed.top', myPort: 'center', dx: 20, dy: -45 }, label: 'P-RO-IN', max: 16, unit: 'bar', source: 'ro-feed' },
{ id: 'p-ro-out', anchor: { to: 'ro-press.top', myPort: 'center', dx: 315, dy: -45 }, label: 'P-RO-OUT', max: 16, unit: 'bar', source: 'ro-press' },
{ id: 'p-dp-1', anchor: { to: 'dp.top', myPort: 'center', dx: 0, dy: -50 }, label: 'P-DP', max: 8, unit: 'bar', source: 'aer-drain' },
];
const SENSORS = [
{ id: 'do', anchor: { to: 'mbr.top', myPort: 'topLeft', dx: -250, dy: -80 }, label: 'DO', value: 8.5, unit: 'ppm', min: 4, max: 12, alarms: { lo: 5, hi: 11 } },
{ id: 'temp', anchor: { to: 'mbr.top', myPort: 'topLeft', dx: -130, dy: -80 }, label: 'Temp', value: 25.0, unit: 'C', min: 0, max: 50, alarms: { lo: 10, hi: 35 } },
{ id: 'tss', anchor: { to: 'mbr.top', myPort: 'topLeft', dx: -10, dy: -80 }, label: 'TSS', value: 0.28, unit: 'mg/L', min: 0, max: 5, alarms: { lo: 0, hi: 2 } },
{ id: 'ph', anchor: { to: 'mbr.top', myPort: 'topLeft', dx: 110, dy: -80 }, label: 'pH', value: 8.5, unit: 'pH', min: 0, max: 14, alarms: { lo: 6.5, hi: 8.5 } },
{ id: 'tss-raw', anchor: { to: 'bw-a1.bot', myPort: 'topLeft', dx: -68, dy: 75 }, label: 'TSS-RAW', value: 1.42, unit: 'mg/L', min: 0, max: 5, alarms: { lo: 0, hi: 3 } },
];
const LEDS = [
{ id: 'sys-power', anchor: { absolute: { x: 40, y: 35 } }, label: 'PWR' },
{ id: 'input-led', anchor: { to: 'bw-a1.in', myPort: 'center', dx: -31, dy: -21 }, label: 'IN' },
];
const TILES = [
{ id: 'flow-in', x: 1830, y: 520, label: 'FLOW IN', value: '0.0', unit: 'L/min', w: 130, h: 50 },
{ id: 'flow-out', x: 1830, y: 580, label: 'FLOW OUT', value: '0.0', unit: 'L/min', w: 130, h: 50 },
{ id: 'recovery', x: 1830, y: 640, label: 'RECOVERY', value: '0.0', unit: '%', w: 130, h: 50 },
{ id: 'alarms', x: 1830, y: 700, label: 'ACTIVE ALARMS', value: '0', unit: '', w: 130, h: 50 },
];
const PANELS = [
{ id: 'cep', x: 40, y: 510, w: 410, h: 210, label: 'CEP SYSTEM' },
{ id: 'cip', x: 40, y: 740, w: 470, h: 125, label: 'CIP SYSTEM' },
{ id: 'mbr-frame', x: 760, y: 600, w: 500, h: 200, label: '' },
];
// Detect orthogonal crossings between resolved pipes.
// Returns map: pipeId → [{x, y}, ...] of points where THIS pipe should jump (gap).
// Convention: vertical pipe jumps over horizontal pipe (vertical gets gap).
function detectCrossings(resolvedPipes) {
const gapsByPipe = {};
for (let i = 0; i < resolvedPipes.length; i++) {
const A = resolvedPipes[i];
for (let j = 0; j < resolvedPipes.length; j++) {
if (i === j) continue;
const B = resolvedPipes[j];
for (let a = 1; a < A.points.length; a++) {
const [ax1, ay1] = A.points[a - 1];
const [ax2, ay2] = A.points[a];
const aIsV = (ax1 === ax2 && ay1 !== ay2);
if (!aIsV) continue;
for (let b = 1; b < B.points.length; b++) {
const [bx1, by1] = B.points[b - 1];
const [bx2, by2] = B.points[b];
const bIsH = (by1 === by2 && bx1 !== bx2);
if (!bIsH) continue;
// A vertical at x=ax1, y∈[ayMin..ayMax]; B horizontal at y=by1, x∈[bxMin..bxMax]
const ayMin = Math.min(ay1, ay2), ayMax = Math.max(ay1, ay2);
const bxMin = Math.min(bx1, bx2), bxMax = Math.max(bx1, bx2);
if (ax1 > bxMin && ax1 < bxMax && by1 > ayMin && by1 < ayMax) {
const id = A.spec.id;
gapsByPipe[id] = gapsByPipe[id] || [];
if (!gapsByPipe[id].some(g => g.x === ax1 && g.y === by1)) {
gapsByPipe[id].push({ x: ax1, y: by1 });
}
}
}
}
}
}
return gapsByPipe;
}
function buildScene() {
const C = global.INVYONE_COMP;
resolveAnchors();
let ports = computePorts();
let resolvedPipes = PIPES.map(p => ({ spec: p, points: resolvePipe(p, ports) }));
resolveOnPipeAnchors(resolvedPipes);
// Re-compute ports + pipes after on-pipe components are placed (in case anything depends)
ports = computePorts();
resolvedPipes = PIPES.map(p => ({ spec: p, points: resolvePipe(p, ports) }));
const out = [];
const gapsByPipe = detectCrossings(resolvedPipes);
PANELS.forEach(p => out.push(C.panel(p)));
resolvedPipes.forEach(rp => out.push(C.pipe({ id: rp.spec.id, points: rp.points, gaps: gapsByPipe[rp.spec.id] || [] })));
TANKS.forEach(t => {
if (t.type === 'ac') {
out.push(C.acTank({ id: t.id, x: t.x, y: t.y, w: t.w, h: t.h, label: t.label, color: t.color }));
} else if (t.type === 'dosing') {
out.push(C.dosingTank({ id: t.id, x: t.x, y: t.y, label: t.label, color: t.color }));
} else if (t.type === 'mbr') {
// Aeration bubbles: 7 circles distributed across MBR width, animated when .aerating
const bubbleSpots = [60, 130, 195, 250, 305, 370, 440];
const bubbles = bubbleSpots.map((bx, i) => `<circle class="bubble" cx="${bx}" cy="${t.h - 10}" r="${1.6 + (i % 3) * 0.5}" fill="#5af9ff" opacity="0" style="animation-delay:${(i * 0.42).toFixed(2)}s"/>`).join('\n ');
out.push(`
<g class="comp comp-tank comp-tank-mbr" data-comp-id="${t.id}" data-comp-type="tank" transform="translate(${t.x} ${t.y})">
<text x="${t.w / 2}" y="-8" class="comp-label" font-size="13" text-anchor="middle">${t.label}</text>
<rect class="tank-shell" x="0" y="0" width="${t.w}" height="${t.h}" rx="4" fill="url(#metal-tank)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
<rect x="3" y="3" width="${t.w - 6}" height="${t.h - 6}" fill="#071326" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
<clipPath id="tank-${t.id}-clip"><rect x="3" y="3" width="${t.w - 6}" height="${t.h - 6}"/></clipPath>
<rect x="3" y="${t.h * 0.55}" width="${t.w - 6}" height="${t.h * 0.45 - 3}" fill="${t.color}" opacity="0.28" clip-path="url(#tank-${t.id}-clip)"/>
<rect id="tank-${t.id}-liquid" x="3" y="${t.h - 3}" width="${t.w - 6}" height="0" fill="${t.color}" opacity="0.32" clip-path="url(#tank-${t.id}-clip)"/>
<ellipse id="tank-${t.id}-surface" cx="${t.w / 2}" cy="${t.h - 3}" rx="${(t.w - 6) / 2 - 4}" ry="2.5" fill="${t.color}" opacity="0" clip-path="url(#tank-${t.id}-clip)"/>
<g class="mbr-bubbles" pointer-events="none" clip-path="url(#tank-${t.id}-clip)">
${bubbles}
</g>
<rect id="tank-${t.id}-level-bg" x="${t.w / 2 - 32}" y="62" width="64" height="22" rx="3" fill="#000" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
<text id="tank-${t.id}-text" x="${t.w / 2}" y="78" class="tank-text" font-size="14" text-anchor="middle">0.0%</text>
</g>`);
} else {
out.push(C.tank({ id: t.id, x: t.x, y: t.y, w: t.w, h: t.h, label: t.label, color: t.color, nozzles: t.nozzles }));
}
});
FILTERS.forEach(f => out.push(C.filter({ id: f.id, x: f.x, y: f.y, label: f.label })));
MODULES.forEach(m => {
if (m.type === 'membrane') out.push(C.membraneBank({ id: m.id, x: m.x, y: m.y, count: m.count, label: m.label, accent: m.accent, orientation: m.orientation }));
else if (m.type === 'carbon') out.push(C.carbonBank({ id: m.id, x: m.x, y: m.y, count: m.count, label: m.label }));
});
PUMPS.forEach(p => {
if (p.id === 'air-blower') out.push(C.airBlower({ id: p.id, x: p.x, y: p.y, label: p.label }));
else if (p.orient === 'v') out.push(C.pumpV({ id: p.id, x: p.x, y: p.y, label: p.label }));
else out.push(C.pump({ id: p.id, x: p.x, y: p.y, label: p.label }));
});
VALVES.forEach(v => out.push(C.valve({ ...v, orientation: v._orientation || 'h' })));
SENSORS.forEach(s => out.push(C.sensor(s)));
GAUGES.forEach(g => out.push(C.pressureGauge(g)));
LEDS.forEach(l => out.push(C.led(l)));
TILES.forEach(t => out.push(C.statusTile(t)));
return out.join('\n');
}
global.INVYONE_TOPO = { TANKS, PUMPS, VALVES, PIPES, FILTERS, MODULES, GAUGES, SENSORS, LEDS, TILES, PANELS, buildScene, computePorts, resolvePipe };
})(window);