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>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
|
||||
function ScadaIframe() {
|
||||
const params = useSearchParams();
|
||||
// ?worker=<user_id> 를 iframe 으로 전달해서 SCADA emergency 시 그 사용자 폰으로 알람 push.
|
||||
// 미지정이면 로컬 시연 모드 (폰 push 없음).
|
||||
const worker = params.get("worker") ?? "";
|
||||
const src = worker
|
||||
? `/scada-demo/index.html?worker=${encodeURIComponent(worker)}`
|
||||
: "/scada-demo/index.html";
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={src}
|
||||
title="INVYONE SCADA Stage-2 Demo"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "calc(100vh - 60px)",
|
||||
border: 0,
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScadaDemoPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ScadaIframe />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import { RequireAuth } from "@/components/auth/AuthGuard";
|
||||
|
||||
export default function MobileLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<RequireAuth>{children}</RequireAuth>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { TokenManager } from "@/lib/auth/tokenManager";
|
||||
import "@/styles/mobile-alarm.css";
|
||||
|
||||
type Severity = "CRITICAL" | "WARNING" | "INFO";
|
||||
|
||||
type AlarmPayload = {
|
||||
code: string;
|
||||
severity: Severity;
|
||||
title: string;
|
||||
message: string;
|
||||
location: string;
|
||||
comp?: string;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
const MOCK_ALARM: AlarmPayload = {
|
||||
code: "P-IN-HH",
|
||||
severity: "CRITICAL",
|
||||
title: "BW-A1 펌프 과압 / 누설 의심",
|
||||
message:
|
||||
"BW-A1 토출 압력이 정상 운전 범위(4.0~5.0 bar)를 초과했습니다.\n현장 점검이 즉시 필요합니다.",
|
||||
location: "펌프룸 A · BW-A1 (원수 취수펌프 #1)",
|
||||
comp: "bw-a1",
|
||||
ts: 0,
|
||||
};
|
||||
|
||||
const RECONNECT_DELAY_MS = 3000;
|
||||
|
||||
// Wake Lock API — 표준이지만 일부 브라우저에서 미지원이라 옵셔널 체이닝.
|
||||
type WakeLockSentinel = { release: () => Promise<void> } & EventTarget;
|
||||
type WakeLockNavigator = Navigator & {
|
||||
wakeLock?: { request: (type: "screen") => Promise<WakeLockSentinel> };
|
||||
};
|
||||
|
||||
export function MobileAlarmClient() {
|
||||
const { user } = useAuth();
|
||||
const [mode, setMode] = useState<"idle" | "alarm">("idle");
|
||||
const [alarm, setAlarm] = useState<AlarmPayload | null>(null);
|
||||
const [clock, setClock] = useState("--:--:--");
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
|
||||
// ─── 시계 ────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const tick = () => {
|
||||
const d = new Date();
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
const ss = String(d.getSeconds()).padStart(2, "0");
|
||||
setClock(`${hh}:${mm}:${ss}`);
|
||||
};
|
||||
tick();
|
||||
const t = setInterval(tick, 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
// ─── 알람음 (Web Audio API 비프, 외부 파일 의존성 없음) ───────────
|
||||
const playAlarmBeep = useCallback(() => {
|
||||
try {
|
||||
let ctx = audioCtxRef.current;
|
||||
if (!ctx) {
|
||||
const Ctor = (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext);
|
||||
ctx = new Ctor();
|
||||
audioCtxRef.current = ctx;
|
||||
}
|
||||
// iOS 등은 사용자 제스처 없이 시작된 ctx 가 suspended 상태일 수 있음
|
||||
if (ctx.state === "suspended") void ctx.resume();
|
||||
const now = ctx.currentTime;
|
||||
// 3회 반복 비프 (650Hz / 0.18s on, 0.12s off)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = "square";
|
||||
osc.frequency.value = 650;
|
||||
const start = now + i * 0.3;
|
||||
const stop = start + 0.18;
|
||||
gain.gain.setValueAtTime(0.0001, start);
|
||||
gain.gain.exponentialRampToValueAtTime(0.35, start + 0.01);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, stop);
|
||||
osc.connect(gain).connect(ctx.destination);
|
||||
osc.start(start);
|
||||
osc.stop(stop + 0.02);
|
||||
}
|
||||
} catch {
|
||||
// 오디오 실패해도 알람 표시는 정상 동작 — silent fail
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ─── 알람 표시 ───────────────────────────────────────────────────
|
||||
const showAlarm = useCallback(
|
||||
(payload: AlarmPayload) => {
|
||||
setAlarm({ ...payload, ts: payload.ts || Date.now() });
|
||||
setMode("alarm");
|
||||
if (typeof navigator !== "undefined" && navigator.vibrate) {
|
||||
navigator.vibrate([400, 120, 400, 120, 800]);
|
||||
}
|
||||
playAlarmBeep();
|
||||
},
|
||||
[playAlarmBeep],
|
||||
);
|
||||
|
||||
const ackAlarm = useCallback(() => {
|
||||
setMode("idle");
|
||||
setAlarm(null);
|
||||
if (typeof navigator !== "undefined" && navigator.vibrate) {
|
||||
navigator.vibrate(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ─── Wake Lock (화면 잠금 회피) ───────────────────────────────────
|
||||
useEffect(() => {
|
||||
const nav = navigator as WakeLockNavigator;
|
||||
if (!nav.wakeLock) return;
|
||||
|
||||
const acquire = async () => {
|
||||
try {
|
||||
if (wakeLockRef.current) return;
|
||||
wakeLockRef.current = await nav.wakeLock!.request("screen");
|
||||
wakeLockRef.current.addEventListener("release", () => {
|
||||
wakeLockRef.current = null;
|
||||
});
|
||||
} catch {
|
||||
// 사용자 권한 없거나 미지원 — 무시
|
||||
}
|
||||
};
|
||||
|
||||
void acquire();
|
||||
|
||||
// 탭 복귀 시 lock 이 풀려있을 수 있으므로 재획득
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "visible") void acquire();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
void wakeLockRef.current?.release().catch(() => {});
|
||||
wakeLockRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ─── WebSocket 연결 (auto reconnect) ─────────────────────────────
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const token = TokenManager.getToken();
|
||||
if (!token) return;
|
||||
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
// /api/demo/* 는 시연 전용 path. 운영 Traefik 의 PathPrefix(`/api`) 라우트로 backend 까지 도달.
|
||||
const url = `${proto}://${window.location.host}/api/demo/ws/alarm?token=${encodeURIComponent(token)}`;
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
const connect = () => {
|
||||
if (cancelled) return;
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => setConnected(true);
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
if (!cancelled) {
|
||||
reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
// close 이벤트가 곧 따라옴 — 거기서 재연결 처리
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data?.type === "alarm" && data?.payload) {
|
||||
showAlarm(data.payload as AlarmPayload);
|
||||
}
|
||||
} catch {
|
||||
// 파싱 실패는 silent
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (ws && ws.readyState <= 1) ws.close();
|
||||
};
|
||||
}, [showAlarm]);
|
||||
|
||||
// ─── 시연 안전망 — ?test=1 자동 발동 ──────────────────────────────
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get("test") === "1") {
|
||||
const t = setTimeout(() => showAlarm(MOCK_ALARM), 800);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [showAlarm]);
|
||||
|
||||
return (
|
||||
<div className={`m-shell ${mode === "alarm" ? "m-shell-alarm" : ""}`}>
|
||||
<header className="m-head">
|
||||
<span className="m-brand">🛡 INVYONE EHS</span>
|
||||
<span className={`m-conn ${connected ? "ok" : "off"}`}>
|
||||
<span className="m-dot" />
|
||||
{connected ? "실시간 연결됨" : "오프라인 (재연결 중...)"}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{mode === "idle" && (
|
||||
<main className="m-idle">
|
||||
<div className="m-glow-orb" />
|
||||
<div className="m-clock">{clock}</div>
|
||||
<div className="m-user">{user?.user_name ?? "작업자"}</div>
|
||||
{user?.dept_name && <div className="m-user-dept">{user.dept_name}</div>}
|
||||
<div className="m-idle-hint">
|
||||
설비 알람이 발생하면 진동과 함께
|
||||
<br />이 화면에 풀스크린으로 표시됩니다
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
|
||||
{mode === "alarm" && alarm && (
|
||||
<main className="m-alarm">
|
||||
<div className="m-alarm-bg" />
|
||||
<div className="m-alarm-card">
|
||||
<div className="m-alarm-sev">{alarm.severity}</div>
|
||||
<div className="m-alarm-icon">⚠</div>
|
||||
<div className="m-alarm-code">{alarm.code}</div>
|
||||
<div className="m-alarm-title">{alarm.title}</div>
|
||||
<div className="m-alarm-loc">📍 {alarm.location}</div>
|
||||
<div className="m-alarm-msg">{alarm.message}</div>
|
||||
<div className="m-alarm-actions">
|
||||
<button className="m-btn-ack" onClick={ackAlarm}>
|
||||
✓ 확인 (ACK) — 현장 출동
|
||||
</button>
|
||||
<button className="m-btn-skip" onClick={ackAlarm}>
|
||||
나중에
|
||||
</button>
|
||||
</div>
|
||||
<div className="m-alarm-time">
|
||||
발생: {new Date(alarm.ts).toLocaleTimeString("ko-KR")}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
|
||||
{/* 시연 안전망 — 우상단 점 클릭으로 mock 알람 트리거 */}
|
||||
<button
|
||||
className="m-test-trigger"
|
||||
onClick={() => (mode === "idle" ? showAlarm(MOCK_ALARM) : ackAlarm())}
|
||||
aria-label="test trigger"
|
||||
title="test trigger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { MobileAlarmClient } from "./MobileAlarmClient";
|
||||
|
||||
export default function MobilePage() {
|
||||
return <MobileAlarmClient />;
|
||||
}
|
||||
@@ -0,0 +1,963 @@
|
||||
/* INVYONE Stage-2 — SCADA Dark Theme */
|
||||
|
||||
:root {
|
||||
--bg: #050a18;
|
||||
--bg-elevated: #081326;
|
||||
--bg-darker: #030814;
|
||||
--stroke: #aaaaaa;
|
||||
--stroke-strong: #ffffff;
|
||||
--flow: #5af9ff;
|
||||
--active: #7cff3a;
|
||||
--warning: #ff8a3a;
|
||||
--alarm: #ff4f9a;
|
||||
--idle: #2a3f5a;
|
||||
--text: #ffffff;
|
||||
--text-muted: #cfd3d8;
|
||||
--text-dim: #8590a3;
|
||||
--selected: #ffd66b;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); font-family: 'Segoe UI', Tahoma, Arial, sans-serif; overflow: hidden; }
|
||||
body { display: flex; flex-direction: column; }
|
||||
|
||||
/* ===== TOP BAR ===== */
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 8px 16px; height: 50px;
|
||||
background: linear-gradient(180deg, #0b1a35, #050a18);
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.topbar h1 { margin: 0; font-size: 18px; color: var(--flow); letter-spacing: 1px; font-weight: 600; }
|
||||
.topbar .subtitle { font-size: 11px; color: var(--text-muted); }
|
||||
.topbar .spacer { flex: 1; }
|
||||
.topbar .mode-display {
|
||||
padding: 4px 12px; background: var(--bg-elevated); border: 1px solid var(--stroke);
|
||||
font-family: Consolas, monospace; font-size: 14px; color: var(--active);
|
||||
}
|
||||
.topbar .mode-display .label { color: var(--text-muted); margin-right: 8px; font-size: 11px; }
|
||||
.topbar .clock { font-family: Consolas, monospace; font-size: 14px; color: var(--text); }
|
||||
|
||||
/* ===== MAIN LAYOUT ===== */
|
||||
.main {
|
||||
display: flex; flex: 1; min-height: 0;
|
||||
}
|
||||
.scene-wrap {
|
||||
flex: 1; min-width: 0; position: relative;
|
||||
background: radial-gradient(ellipse at center, #0a1628 0%, #050a18 100%);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.scene-wrap svg { width: 100%; height: 100%; }
|
||||
|
||||
.sidebar {
|
||||
width: 380px; flex-shrink: 0;
|
||||
background: var(--bg-elevated);
|
||||
border-left: 1px solid var(--stroke);
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar-section { border-bottom: 1px solid #1a2a45; padding: 12px; }
|
||||
.sidebar-section h2 { margin: 0 0 8px 0; font-size: 11px; color: var(--text-muted); letter-spacing: 1.5px; text-transform: uppercase; }
|
||||
|
||||
/* ===== MODE BUTTONS ===== */
|
||||
.modes { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
|
||||
.modes button {
|
||||
padding: 8px 4px; font-size: 11px; font-weight: 600;
|
||||
background: var(--bg-darker); color: var(--text-muted);
|
||||
border: 1px solid var(--stroke); cursor: pointer; transition: all .15s;
|
||||
}
|
||||
.modes button:hover { background: #11203a; color: var(--text); }
|
||||
.modes button.active { background: var(--active); color: #000; border-color: var(--active); }
|
||||
|
||||
.controls-row { display: flex; gap: 6px; margin-top: 8px; }
|
||||
.controls-row button {
|
||||
flex: 1; padding: 6px; font-size: 11px;
|
||||
background: var(--bg-darker); color: var(--text); border: 1px solid var(--stroke); cursor: pointer;
|
||||
}
|
||||
.controls-row button:hover { background: #11203a; }
|
||||
.controls-row button.danger { color: var(--alarm); border-color: var(--alarm); }
|
||||
.controls-row button.warn { color: var(--warning); border-color: var(--warning); }
|
||||
|
||||
.global-slider { display: flex; align-items: center; gap: 8px; margin-top: 8px; font-size: 11px; color: var(--text-muted); }
|
||||
.global-slider input { flex: 1; }
|
||||
|
||||
/* ===== INSPECTOR ===== */
|
||||
.inspector { flex: 1; padding: 12px; overflow-y: auto; }
|
||||
.inspector h3 { margin: 0 0 12px 0; font-size: 14px; color: var(--flow); }
|
||||
.inspector .row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 12px; border-bottom: 1px dashed #1a2a45; }
|
||||
.inspector .row span { color: var(--text-muted); }
|
||||
.inspector .row b { color: var(--text); font-family: Consolas, monospace; }
|
||||
.inspector .row b.on { color: var(--active); }
|
||||
.inspector .row b.off { color: var(--idle); }
|
||||
.inspector input[type="range"] { width: 100%; margin: 8px 0; accent-color: var(--flow); }
|
||||
.inspector .actions { margin-top: 12px; display: flex; gap: 6px; }
|
||||
.inspector .actions button {
|
||||
flex: 1; padding: 8px; font-size: 12px; font-weight: 600;
|
||||
background: var(--bg-darker); color: var(--text); border: 1px solid var(--stroke); cursor: pointer;
|
||||
}
|
||||
.inspector .actions button:hover { background: #11203a; }
|
||||
.inspector .alarm-tag { padding: 2px 6px; border-radius: 2px; font-size: 10px; }
|
||||
.inspector .alarm-tag.hh, .inspector .alarm-tag.ll, .inspector .alarm-tag.hi, .inspector .alarm-tag.lo { background: var(--alarm); color: #000; }
|
||||
.inspector .alarm-tag.hl, .inspector .alarm-tag.lh { background: var(--warning); color: #000; }
|
||||
.inspector .muted { color: var(--text-dim); font-size: 11px; }
|
||||
.inspector .muted.small { font-size: 10px; margin-top: 8px; }
|
||||
.inspector .inspector-empty { color: var(--text-dim); font-size: 12px; }
|
||||
.inspector .inspector-empty .hint { margin: 12px 0 0 16px; padding: 0; }
|
||||
.inspector .inspector-empty .hint li { margin: 4px 0; }
|
||||
|
||||
/* ===== EVENT LOG ===== */
|
||||
.eventlog-wrap {
|
||||
height: 160px; flex-shrink: 0;
|
||||
background: var(--bg-darker);
|
||||
border-top: 1px solid var(--stroke);
|
||||
padding: 8px 12px; display: flex; flex-direction: column;
|
||||
}
|
||||
.eventlog-wrap h2 { margin: 0 0 6px 0; font-size: 11px; color: var(--text-muted); letter-spacing: 1.5px; }
|
||||
#event-log {
|
||||
flex: 1; overflow-y: auto; font-family: Consolas, 'Courier New', monospace; font-size: 11px;
|
||||
}
|
||||
.event-line { padding: 1px 0; }
|
||||
.event-line.event-info { color: var(--text-muted); }
|
||||
.event-line.event-warn { color: var(--warning); }
|
||||
.event-line.event-alarm { color: var(--alarm); font-weight: 600; }
|
||||
|
||||
/* ============================================================
|
||||
SVG COMPONENT STATES
|
||||
============================================================ */
|
||||
|
||||
/* Component base */
|
||||
.comp { cursor: pointer; }
|
||||
.comp:not(.comp-pipe):not(.comp-panel):not(.comp-tile) { transition: filter .15s; }
|
||||
.comp:not(.comp-pipe):not(.comp-panel):hover { filter: drop-shadow(0 0 4px var(--flow)) brightness(1.15); }
|
||||
.comp.selected { outline: 2px dashed var(--selected); }
|
||||
.comp .comp-label { fill: var(--text); font-family: Arial, sans-serif; }
|
||||
|
||||
/* Tank text */
|
||||
.tank-text { fill: var(--text); font-family: Consolas, monospace; }
|
||||
.tank-shell { /* metal frame */ }
|
||||
|
||||
/* Tank alarm pulse on level-bg */
|
||||
@keyframes pulse-alarm {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
.comp.comp-tank.alarm-hh [id$="-level-bg"],
|
||||
.comp.comp-tank.alarm-ll [id$="-level-bg"] { animation: pulse-alarm 0.8s infinite; }
|
||||
.comp.comp-tank.alarm-hl [id$="-level-bg"],
|
||||
.comp.comp-tank.alarm-lh [id$="-level-bg"] { animation: pulse-alarm 1.4s infinite; }
|
||||
|
||||
/* PUMP impeller spin (running) */
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.pump-impeller, .blower-fan {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
.comp.comp-pump.running .pump-impeller,
|
||||
.comp.comp-pump.running .blower-fan { animation: spin 0.8s linear infinite; }
|
||||
.comp.comp-pump.running .pump-led { fill: var(--active); }
|
||||
.comp.comp-pump:not(.running) .pump-led { fill: var(--idle); }
|
||||
|
||||
/* VALVE handle rotation */
|
||||
.valve-handle {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
transition: transform .25s;
|
||||
}
|
||||
.comp.comp-valve.closed .valve-handle { transform: rotate(90deg); }
|
||||
|
||||
/* FLOW lines — wave highlight on top of pipe body fluid (no visible dashes when idle) */
|
||||
.flow {
|
||||
opacity: 0;
|
||||
stroke-dasharray: 12 28;
|
||||
stroke-width: 1.8;
|
||||
transition: opacity 0.4s;
|
||||
}
|
||||
.flow.active {
|
||||
opacity: 0.9;
|
||||
animation: flow-march 1.4s linear infinite;
|
||||
}
|
||||
.flow.blocked {
|
||||
stroke: var(--alarm) !important;
|
||||
opacity: 0.95;
|
||||
animation: flow-blink 0.6s linear infinite;
|
||||
}
|
||||
@keyframes flow-march {
|
||||
from { stroke-dashoffset: 0; }
|
||||
to { stroke-dashoffset: -40; }
|
||||
}
|
||||
@keyframes flow-blink {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Pipe fluid fill — uses fixed large dasharray (3000 covers the longest path).
|
||||
Idle: dashoffset = 3000 → fully hidden. Active: 0 → revealed.
|
||||
No JS measurement needed; CSS does it all. */
|
||||
.pipe-fluid {
|
||||
pointer-events: none;
|
||||
stroke-dasharray: 3000;
|
||||
stroke-dashoffset: 3000;
|
||||
transition: stroke-dashoffset 1.2s linear;
|
||||
}
|
||||
.comp-pipe.active .pipe-fluid {
|
||||
stroke-dashoffset: 0 !important;
|
||||
}
|
||||
/* CHEM dosing lines — green fluid (chemical, not raw water) */
|
||||
#pipe-p-chem-uf-fluid,
|
||||
#pipe-p-chemuf-out-fluid {
|
||||
stroke: #7cff3a !important;
|
||||
}
|
||||
#pipe-p-chem-uf-flow,
|
||||
#pipe-p-chemuf-out-flow {
|
||||
stroke: #7cff3a !important;
|
||||
}
|
||||
/* CIP cleaning lines — magenta */
|
||||
#pipe-p-cip-ro-fluid,
|
||||
#pipe-p-ciptank-pump-fluid {
|
||||
stroke: #ff4f9a !important;
|
||||
}
|
||||
#pipe-p-cip-ro-flow,
|
||||
#pipe-p-ciptank-pump-flow {
|
||||
stroke: #ff4f9a !important;
|
||||
}
|
||||
/* Hide dash on filter internal flow when idle (no point-line look) */
|
||||
.filter-internal-flow {
|
||||
opacity: 0;
|
||||
stroke-dasharray: 6 12;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.comp-filter.active .filter-internal-flow {
|
||||
opacity: 0.9;
|
||||
animation: flow-march 1.0s linear infinite;
|
||||
}
|
||||
/* Membrane internal downflow — wave-style */
|
||||
.membrane-flow {
|
||||
opacity: 0;
|
||||
stroke-dasharray: 8 16;
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
.comp-module.active .membrane-flow {
|
||||
opacity: 0.85;
|
||||
animation: flow-march 1.2s linear infinite;
|
||||
}
|
||||
|
||||
/* Module fluid level — solid color shift when active (no pulse) */
|
||||
.comp-module .cell-fluid, .comp-module .cartridge-fluid {
|
||||
transition: opacity .35s, fill .25s;
|
||||
opacity: 0.30;
|
||||
}
|
||||
.comp-module.active .cell-fluid,
|
||||
.comp-module.active .cartridge-fluid {
|
||||
opacity: 0.85;
|
||||
}
|
||||
/* Module manifold flow lines on active — slightly brighter, no glow noise */
|
||||
.comp-module.active .module-flow {
|
||||
stroke-width: 2.6;
|
||||
}
|
||||
/* Manifold pipe — static metal, no glow */
|
||||
.manifold-header { /* no transition / filter — keep it calm */ }
|
||||
.comp-module .cell-fiber { opacity: 0.45; }
|
||||
.tmp-readout, .mode-readout { pointer-events: none; }
|
||||
.comp-module.active .tmp-readout text:last-child { fill: #7cff3a; }
|
||||
|
||||
/* UF inlet valve indicators — static, no transition glow */
|
||||
.cell-inlet-valve { opacity: 0.85; }
|
||||
.cell-outlet-stub { opacity: 0.7; }
|
||||
|
||||
/* BACKWASH mode — reverse-flow color (cyan → orange) on UF system */
|
||||
.comp-module.mode-backwash .cell-fluid { fill: #ff8a3a !important; }
|
||||
.comp-module.mode-backwash .membrane-flow { stroke: #ff8a3a !important; }
|
||||
.comp-module.mode-backwash .module-flow { stroke: #ff8a3a !important; }
|
||||
.comp-module.mode-backwash .cell-inlet-valve circle { stroke: #ff8a3a !important; }
|
||||
.comp-module.mode-backwash .cell-inlet-valve line:last-of-type { stroke: #ff8a3a !important; }
|
||||
.comp-module.mode-backwash .manifold-header { filter: drop-shadow(0 0 4px #ff8a3a) !important; }
|
||||
|
||||
/* CIP mode — chemical-clean magenta */
|
||||
.comp-module.mode-cip .cell-fluid { fill: #ff4f9a !important; }
|
||||
.comp-module.mode-cip .membrane-flow { stroke: #ff4f9a !important; }
|
||||
.comp-module.mode-cip .module-flow { stroke: #ff4f9a !important; }
|
||||
|
||||
/* Filter cartridge: housing fills with fluid + internal dash flow when feed pump is running */
|
||||
.filter-element { transition: fill .35s, stroke .25s; }
|
||||
.filter-fluid { transition: opacity .35s; }
|
||||
.filter-housing-fluid { transition: opacity .35s; }
|
||||
.filter-core-flow { transition: opacity .25s; }
|
||||
.filter-arrow { transition: opacity .25s, fill .25s; }
|
||||
|
||||
.comp-filter:hover .filter-element { stroke: #5af9ff; }
|
||||
|
||||
/* ACTIVE state — visible fluid + flowing dashes inside */
|
||||
.comp-filter.active .filter-housing-bg { fill: #0a2438; }
|
||||
.comp-filter.active .filter-housing-fluid { opacity: 0.22; }
|
||||
.comp-filter.active .filter-element { fill: #1a4a5a; stroke: #5af9ff; }
|
||||
.comp-filter.active .filter-fluid { opacity: 0.55; }
|
||||
.comp-filter.active .filter-core-flow { opacity: 1; animation: flow-march 0.5s linear infinite; }
|
||||
.comp-filter.active .filter-arrow { opacity: 1; fill: #7cff3a; }
|
||||
/* MBR aeration: when AIR BLOWER drives MBR, tank shell pulses cyan */
|
||||
@keyframes aerationGlow {
|
||||
0%, 100% { stroke: #fff; filter: none; }
|
||||
50% { stroke: #5af9ff; filter: drop-shadow(0 0 6px #5af9ff); }
|
||||
}
|
||||
.comp-tank-mbr.aerating .tank-shell {
|
||||
animation: aerationGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* MBR aeration bubbles — rise from bottom of liquid, fade out near surface */
|
||||
.mbr-bubbles .bubble {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
@keyframes bubbleRise {
|
||||
0% { transform: translateY(0px); opacity: 0; }
|
||||
10% { opacity: 0.85; }
|
||||
85% { opacity: 0.85; }
|
||||
100% { transform: translateY(-110px); opacity: 0; }
|
||||
}
|
||||
.comp-tank-mbr.aerating .mbr-bubbles .bubble {
|
||||
animation: bubbleRise 2.4s ease-in infinite;
|
||||
}
|
||||
|
||||
/* Sensor alarm */
|
||||
.comp-sensor { transition: filter .15s; }
|
||||
.comp-sensor.alarm rect { fill: #2a0a14 !important; stroke: var(--alarm) !important; }
|
||||
.comp-sensor.alarm .sensor-value { fill: var(--alarm) !important; }
|
||||
.comp-sensor.alarm { animation: pulse-alarm 0.8s infinite; }
|
||||
|
||||
/* Sensor */
|
||||
.sensor-label { fill: var(--text-muted); }
|
||||
.sensor-value { fill: var(--text); font-family: Consolas, monospace; }
|
||||
.sensor-unit { fill: var(--text-dim); }
|
||||
|
||||
/* Gauge */
|
||||
.gauge-readout { fill: var(--text); font-family: Consolas, monospace; }
|
||||
.gauge-title { fill: var(--text); }
|
||||
.gauge-unit { fill: var(--text-muted); }
|
||||
.gauge-needle { transition: transform .25s ease-out; }
|
||||
/* needle rotation driven by SVG transform attribute via JS — rotate(angle 40 40) */
|
||||
|
||||
/* Tile */
|
||||
.tile-label { fill: var(--text-muted); }
|
||||
.tile-value { fill: var(--active); font-family: Consolas, monospace; font-weight: bold; }
|
||||
.tile-unit { fill: var(--text-muted); }
|
||||
.comp-tile.alarm rect { fill: #2a0a14 !important; stroke: var(--alarm) !important; }
|
||||
.comp-tile.alarm .tile-value { fill: var(--alarm) !important; }
|
||||
|
||||
/* LED */
|
||||
.led-status { transition: fill .15s; }
|
||||
|
||||
/* Panel frame label */
|
||||
.panel-label { fill: var(--warning); font-family: Arial; font-weight: 600; }
|
||||
|
||||
/* Cartridge */
|
||||
.cartridge { transition: opacity .2s; }
|
||||
.cartridge.isolated { opacity: 0.3; }
|
||||
.cartridge.isolated .cartridge-shell { stroke: var(--alarm) !important; stroke-dasharray: 3 2; }
|
||||
.cartridge.isolated .cartridge-fluid { opacity: 0.05 !important; }
|
||||
|
||||
/* Selected outline */
|
||||
.comp.selected > rect:first-of-type,
|
||||
.comp.selected > circle:first-of-type {
|
||||
stroke: var(--selected) !important;
|
||||
stroke-width: 2 !important;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg-darker); }
|
||||
::-webkit-scrollbar-thumb { background: #2a3f5a; border-radius: 0; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #3a5278; }
|
||||
|
||||
/* ============================================================
|
||||
EMERGENCY SCENARIO — 경고시스템 시연 영역
|
||||
============================================================ */
|
||||
|
||||
/* 경고 버튼 (사이드바) */
|
||||
.controls-row button.emergency {
|
||||
background: linear-gradient(180deg, #5a102c, #2a0a14);
|
||||
color: #ff8eb6;
|
||||
border: 1px solid #ff4f9a;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
text-shadow: 0 0 4px #ff4f9a;
|
||||
box-shadow: 0 0 0 0 rgba(255, 79, 154, 0.7);
|
||||
animation: emergencyPulse 2.5s ease-out infinite;
|
||||
}
|
||||
.controls-row button.emergency:hover {
|
||||
background: linear-gradient(180deg, #7a1a3c, #3a1a24);
|
||||
color: #fff;
|
||||
}
|
||||
@keyframes emergencyPulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(255, 79, 154, 0.6); }
|
||||
70% { box-shadow: 0 0 0 12px rgba(255, 79, 154, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(255, 79, 154, 0); }
|
||||
}
|
||||
|
||||
/* P&ID 안의 시나리오 타겟 강조 — 강력하게 */
|
||||
.comp.scenario-target { filter: drop-shadow(0 0 8px var(--alarm)); }
|
||||
@keyframes scenarioFlashStrong {
|
||||
0%, 100% {
|
||||
filter: drop-shadow(0 0 8px var(--alarm)) brightness(1.0) saturate(1.0);
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 24px var(--alarm)) drop-shadow(0 0 8px #fff) brightness(1.45) saturate(1.4);
|
||||
}
|
||||
}
|
||||
.comp.scenario-flash { animation: scenarioFlashStrong 0.85s ease-in-out infinite; }
|
||||
|
||||
/* P&ID 알람 핀 — 펌프 위에 떠있는 큰 ⚠ 마커 */
|
||||
.scenario-alarm-pin .pin-ring-1,
|
||||
.scenario-alarm-pin .pin-ring-2 {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
@keyframes pinRingPulse {
|
||||
0% { transform: scale(0.9); opacity: 0.7; }
|
||||
100% { transform: scale(1.6); opacity: 0; }
|
||||
}
|
||||
.scenario-alarm-pin .pin-ring-1 { animation: pinRingPulse 1.4s ease-out infinite; }
|
||||
.scenario-alarm-pin .pin-ring-2 { animation: pinRingPulse 1.4s ease-out 0.5s infinite; }
|
||||
@keyframes pinIconBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
.scenario-alarm-pin .pin-core { animation: pinIconBlink 0.6s infinite; }
|
||||
|
||||
/* 상단 알람 배너 */
|
||||
.alarm-banner {
|
||||
position: fixed;
|
||||
top: 50px;
|
||||
left: 0; right: 0;
|
||||
background: linear-gradient(90deg, #5a102c 0%, #ff4f9a 50%, #5a102c 100%);
|
||||
background-size: 200% 100%;
|
||||
padding: 11px 24px;
|
||||
border-bottom: 2px solid #ff4f9a;
|
||||
border-top: 1px solid rgba(255,255,255,0.2);
|
||||
color: #fff;
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
font-family: Consolas, monospace;
|
||||
z-index: 105; /* 모달(100) 위, 핸드폰(110) 아래 */
|
||||
transform: translateY(-110%);
|
||||
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1), right 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
box-shadow: 0 4px 18px rgba(255, 79, 154, 0.4);
|
||||
}
|
||||
.alarm-banner.with-map { right: 540px; }
|
||||
.alarm-banner.show {
|
||||
transform: translateY(0);
|
||||
animation: bannerScroll 4s linear infinite;
|
||||
}
|
||||
@keyframes bannerScroll {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: 0% 0; }
|
||||
}
|
||||
.alarm-banner-icon {
|
||||
font-size: 22px;
|
||||
animation: pinIconBlink 0.55s infinite;
|
||||
text-shadow: 0 0 8px #fff;
|
||||
}
|
||||
.alarm-banner-severity {
|
||||
background: #fff; color: #ff1f6a;
|
||||
padding: 4px 11px; font-weight: 800; font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
box-shadow: 0 0 8px rgba(255,255,255,0.5);
|
||||
}
|
||||
.alarm-banner-code {
|
||||
font-size: 12px; padding: 4px 10px;
|
||||
background: rgba(0,0,0,0.35);
|
||||
border: 1px solid rgba(255,255,255,0.6);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.alarm-banner-msg {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 0 4px rgba(0,0,0,0.5);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.alarm-banner-loc {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
background: rgba(0,0,0,0.4);
|
||||
padding: 6px 12px;
|
||||
border: 1px solid rgba(255,255,255,0.5);
|
||||
font-size: 12px;
|
||||
}
|
||||
.alarm-banner-loc .loc-pin {
|
||||
font-size: 14px;
|
||||
animation: locPinBounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes locPinBounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
.alarm-banner-loc b { color: #fff; font-weight: 700; }
|
||||
|
||||
/* 모달 — 위치 표시 행 */
|
||||
.modal-location {
|
||||
display: flex; gap: 12px; align-items: center;
|
||||
padding: 10px 18px;
|
||||
background: rgba(255, 79, 154, 0.10);
|
||||
border-bottom: 1px solid rgba(255, 79, 154, 0.3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.modal-location .loc-icon {
|
||||
font-size: 16px;
|
||||
animation: locPinBounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
.modal-location .loc-text { color: var(--text-muted); }
|
||||
.modal-location .loc-text b { color: #fff; font-family: Consolas, monospace; font-weight: 700; }
|
||||
.modal-location .loc-pointer {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #ff8eb6;
|
||||
font-style: italic;
|
||||
animation: pinIconBlink 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
회사 맵 패널 (우측 슬라이드 인)
|
||||
============================================================ */
|
||||
.map-panel {
|
||||
position: fixed;
|
||||
top: 50px;
|
||||
right: 0;
|
||||
bottom: 160px;
|
||||
width: 540px;
|
||||
background: linear-gradient(180deg, #050a18 0%, #081326 100%);
|
||||
border-left: 2px solid #5af9ff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
z-index: 50;
|
||||
box-shadow: -8px 0 32px rgba(90, 249, 255, 0.15);
|
||||
}
|
||||
.map-panel.open { transform: translateX(0); }
|
||||
|
||||
.map-panel-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 10px 16px;
|
||||
background: linear-gradient(180deg, #0b1a35, #050a18);
|
||||
border-bottom: 1px solid #3a5278;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.map-panel-title { color: #5af9ff; font-size: 13px; font-weight: 700; letter-spacing: 1px; }
|
||||
.map-panel-status { color: #7cff3a; font-size: 10px; font-family: Consolas, monospace; }
|
||||
.map-panel-status::before { content: "● "; }
|
||||
|
||||
.map-panel-body { flex: 1; min-height: 0; padding: 10px; }
|
||||
.map-svg { width: 100%; height: 100%; }
|
||||
|
||||
.map-panel-foot {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-darker);
|
||||
border-top: 1px solid #3a5278;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.map-status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #7cff3a;
|
||||
box-shadow: 0 0 6px #7cff3a;
|
||||
animation: pulse-alarm 1.5s infinite;
|
||||
}
|
||||
|
||||
/* 맵 — 알람 핀 깜빡임 */
|
||||
@keyframes alarmPinPulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.18); }
|
||||
}
|
||||
#map-alarm-pin.active {
|
||||
opacity: 1 !important;
|
||||
animation: alarmPinPulse 0.9s ease-in-out infinite;
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
}
|
||||
|
||||
/* 맵 — 경로 fade in */
|
||||
#map-route.active {
|
||||
opacity: 1 !important;
|
||||
animation: dashMarch 0.8s linear infinite;
|
||||
}
|
||||
@keyframes dashMarch {
|
||||
from { stroke-dashoffset: 0; }
|
||||
to { stroke-dashoffset: -20; }
|
||||
}
|
||||
|
||||
/* 맵 — 담당자 이동 (transform transition) */
|
||||
#map-officer { transition: transform 6s linear; }
|
||||
#map-officer.moving > circle { animation: pulse-alarm 0.6s infinite; }
|
||||
|
||||
/* 도착 강조 */
|
||||
.map-building.alarm-target rect {
|
||||
stroke: #ff4f9a !important;
|
||||
stroke-width: 2.5 !important;
|
||||
animation: pulse-alarm 1s infinite;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
핸드폰 알림 (우상단 fixed)
|
||||
============================================================ */
|
||||
.phone-notify {
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
right: -340px;
|
||||
width: 280px;
|
||||
z-index: 110;
|
||||
transition: right 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.phone-notify.show { right: 30px; pointer-events: auto; }
|
||||
.phone-notify.shake .phone-frame { animation: phoneShake 0.4s ease-in-out 0s 8; }
|
||||
@keyframes phoneShake {
|
||||
0%, 100% { transform: translateX(0) rotate(0); }
|
||||
25% { transform: translateX(-3px) rotate(-1deg); }
|
||||
75% { transform: translateX(3px) rotate(1deg); }
|
||||
}
|
||||
|
||||
.phone-frame {
|
||||
background: #050a18;
|
||||
border: 3px solid #2a3f5a;
|
||||
border-radius: 28px;
|
||||
padding: 22px 8px 14px 8px;
|
||||
position: relative;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
|
||||
}
|
||||
.phone-notch {
|
||||
position: absolute; top: 6px; left: 50%; transform: translateX(-50%);
|
||||
width: 80px; height: 14px; border-radius: 8px;
|
||||
background: #2a3f5a;
|
||||
}
|
||||
.phone-screen {
|
||||
background: #0a1628; border-radius: 18px;
|
||||
padding: 14px 10px;
|
||||
min-height: 200px;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.phone-time {
|
||||
text-align: center; color: #fff;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 22px; font-weight: 300;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.phone-card {
|
||||
background: rgba(255, 79, 154, 0.12);
|
||||
border: 1px solid #ff4f9a;
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.phone-card-head {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 10px; color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.phone-app-icon { font-size: 12px; }
|
||||
.phone-app-name { font-weight: 700; color: #ff4f9a; }
|
||||
.phone-time-dim { margin-left: auto; color: var(--text-dim); }
|
||||
.phone-card-title {
|
||||
color: #fff; font-weight: 700; font-size: 13px; margin-bottom: 4px;
|
||||
}
|
||||
.phone-card-msg {
|
||||
color: var(--text-muted); font-size: 11px;
|
||||
line-height: 1.4; margin-bottom: 10px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.phone-card-actions {
|
||||
display: flex; gap: 6px;
|
||||
}
|
||||
.phone-btn {
|
||||
flex: 1; padding: 6px; font-size: 11px;
|
||||
border-radius: 6px; cursor: pointer;
|
||||
background: rgba(255,255,255,0.08); color: #fff;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
}
|
||||
.phone-btn-confirm {
|
||||
background: #ff4f9a; border-color: #ff4f9a; color: #fff; font-weight: 700;
|
||||
}
|
||||
.phone-btn-confirm.tapped {
|
||||
background: #5a102c; transform: scale(0.95);
|
||||
}
|
||||
.phone-btn-confirm.highlight {
|
||||
animation: phoneTapHint 0.5s ease-in-out 4;
|
||||
}
|
||||
@keyframes phoneTapHint {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(255,79,154,0.7); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(255,79,154,0); }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
모달 (CCTV + 담당자)
|
||||
============================================================ */
|
||||
.emergency-modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(2px);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
animation: modalFadeIn 0.3s ease-out;
|
||||
transition: right 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.emergency-modal-backdrop.show { display: flex; }
|
||||
/* 맵이 열리면 backdrop을 좌측 영역으로 좁혀 맵이 보이게 함 */
|
||||
.emergency-modal-backdrop.with-map { right: 540px; }
|
||||
@keyframes modalFadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.emergency-modal {
|
||||
width: min(960px, 92vw);
|
||||
max-height: 88vh;
|
||||
background: linear-gradient(180deg, #081326 0%, #050a18 100%);
|
||||
border: 2px solid #ff4f9a;
|
||||
box-shadow: 0 0 40px rgba(255,79,154,0.4), inset 0 0 20px rgba(255,79,154,0.05);
|
||||
display: flex; flex-direction: column;
|
||||
animation: modalSlideIn 0.4s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
@keyframes modalSlideIn {
|
||||
from { transform: translateY(-20px) scale(0.96); opacity: 0; }
|
||||
to { transform: translateY(0) scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-head {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 18px;
|
||||
background: linear-gradient(90deg, #5a102c, #3a0e1e);
|
||||
border-bottom: 1px solid #ff4f9a;
|
||||
}
|
||||
.modal-head-left { display: flex; gap: 8px; align-items: center; }
|
||||
.modal-severity {
|
||||
background: #ff4f9a; color: #fff;
|
||||
padding: 3px 10px; font-size: 11px; font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
animation: pulse-alarm 0.8s infinite;
|
||||
}
|
||||
.modal-code {
|
||||
font-family: Consolas, monospace; color: #ff8eb6;
|
||||
font-size: 11px; padding: 3px 8px; border: 1px solid #ff4f9a;
|
||||
}
|
||||
.modal-head-title { flex: 1; color: #fff; font-size: 16px; font-weight: 700; }
|
||||
.modal-close {
|
||||
background: transparent; border: none; color: #ff8eb6;
|
||||
font-size: 24px; cursor: pointer; padding: 0 8px;
|
||||
}
|
||||
.modal-close:hover { color: #fff; }
|
||||
|
||||
.modal-body {
|
||||
display: grid; grid-template-columns: 1.4fr 1fr;
|
||||
gap: 16px; padding: 16px;
|
||||
flex: 1; min-height: 0;
|
||||
}
|
||||
|
||||
/* CCTV 영역 (좌) */
|
||||
.modal-cctv {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
background: #000; border: 1px solid #2a3f5a;
|
||||
}
|
||||
.cctv-bar {
|
||||
display: flex; gap: 12px; align-items: center;
|
||||
padding: 6px 10px; font-size: 10px;
|
||||
background: #0a1628; color: var(--text-muted);
|
||||
font-family: Consolas, monospace;
|
||||
}
|
||||
.cctv-rec {
|
||||
color: #ff4f9a; font-weight: 700;
|
||||
animation: pulse-alarm 1.2s infinite;
|
||||
}
|
||||
.cctv-cam { flex: 1; color: #5af9ff; }
|
||||
.cctv-time { color: var(--text); }
|
||||
|
||||
.cctv-screen {
|
||||
position: relative;
|
||||
flex: 1; min-height: 280px;
|
||||
background: #0a0a0a;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cctv-noise {
|
||||
position: absolute; inset: 0;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(255,255,255,0.04) 0 1px, transparent 1px 3px),
|
||||
repeating-linear-gradient(90deg, rgba(255,255,255,0.02) 0 1px, transparent 1px 4px);
|
||||
pointer-events: none;
|
||||
animation: cctvNoise 0.15s steps(4) infinite;
|
||||
}
|
||||
@keyframes cctvNoise {
|
||||
0% { transform: translateY(0); }
|
||||
100% { transform: translateY(2px); }
|
||||
}
|
||||
.cctv-scanline {
|
||||
position: absolute; left: 0; right: 0; height: 30px;
|
||||
background: linear-gradient(180deg, transparent, rgba(90,249,255,0.08), transparent);
|
||||
pointer-events: none;
|
||||
animation: cctvScan 4s linear infinite;
|
||||
}
|
||||
@keyframes cctvScan {
|
||||
0% { top: -30px; }
|
||||
100% { top: 100%; }
|
||||
}
|
||||
.cctv-placeholder {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-direction: column;
|
||||
color: #5a7898;
|
||||
}
|
||||
.cctv-video {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.45s ease;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
.cctv-video.active { opacity: 1; }
|
||||
.cctv-target-box {
|
||||
position: absolute; top: 30%; left: 30%;
|
||||
width: 40%; height: 40%;
|
||||
border: 2px solid #ff4f9a;
|
||||
box-shadow: 0 0 12px rgba(255,79,154,0.5);
|
||||
animation: pulse-alarm 1.2s infinite;
|
||||
}
|
||||
.cctv-target-label {
|
||||
position: absolute; top: -22px; left: 0;
|
||||
background: #ff4f9a; color: #000;
|
||||
padding: 2px 8px; font-size: 10px; font-weight: 700;
|
||||
font-family: Consolas, monospace;
|
||||
}
|
||||
.cctv-fake-pump {
|
||||
font-size: 80px;
|
||||
color: #2a3f5a;
|
||||
filter: drop-shadow(0 0 8px rgba(255,79,154,0.4));
|
||||
animation: spin 1.4s linear infinite;
|
||||
}
|
||||
.cctv-overlay-text {
|
||||
position: absolute; bottom: 14px;
|
||||
font-size: 10px; color: var(--text-dim);
|
||||
font-family: Consolas, monospace;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.cctv-meta {
|
||||
display: flex; gap: 12px;
|
||||
padding: 6px 10px; font-size: 10px;
|
||||
background: #0a1628; color: var(--text-muted);
|
||||
font-family: Consolas, monospace;
|
||||
}
|
||||
.cctv-live { margin-left: auto; color: #ff4f9a; }
|
||||
|
||||
/* 담당자 영역 (우) */
|
||||
.modal-officer {
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
background: rgba(11, 26, 53, 0.6);
|
||||
border: 1px solid #2a3f5a;
|
||||
padding: 14px;
|
||||
}
|
||||
.officer-head {
|
||||
font-size: 11px; color: #5af9ff; font-weight: 700;
|
||||
letter-spacing: 1.5px; text-transform: uppercase;
|
||||
border-bottom: 1px solid #2a3f5a; padding-bottom: 6px;
|
||||
}
|
||||
.officer-card {
|
||||
display: flex; gap: 12px; align-items: center;
|
||||
}
|
||||
.officer-avatar {
|
||||
width: 60px; height: 60px;
|
||||
background: linear-gradient(135deg, #5af9ff, #11203a);
|
||||
color: #000;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px; font-weight: 700;
|
||||
border: 2px solid #5af9ff;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
.officer-avatar img {
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.officer-info { flex: 1; }
|
||||
.officer-name { font-size: 16px; font-weight: 700; color: #fff; }
|
||||
.officer-title { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||
.officer-role { font-size: 10px; color: var(--warning); margin-top: 4px; }
|
||||
|
||||
.officer-contact {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
font-size: 12px;
|
||||
background: var(--bg-darker);
|
||||
padding: 10px;
|
||||
border: 1px solid #1a2a45;
|
||||
}
|
||||
.officer-row { display: flex; justify-content: space-between; padding: 3px 0; }
|
||||
.officer-row span { color: var(--text-muted); }
|
||||
.officer-row b { color: #fff; font-family: Consolas, monospace; font-size: 11px; }
|
||||
.officer-status { color: var(--warning) !important; }
|
||||
.officer-status.delivered { color: #5af9ff !important; }
|
||||
.officer-status.responding { color: var(--warning) !important; }
|
||||
.officer-status.arrived { color: var(--active) !important; }
|
||||
|
||||
.officer-action {
|
||||
margin-top: auto;
|
||||
padding: 12px;
|
||||
background: linear-gradient(180deg, #ff4f9a, #5a102c);
|
||||
color: #fff;
|
||||
border: 1px solid #ff4f9a;
|
||||
font-size: 13px; font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
display: flex; gap: 8px; align-items: center; justify-content: center;
|
||||
animation: pulse-alarm 1.6s infinite;
|
||||
}
|
||||
.officer-action:hover { background: #ff4f9a; }
|
||||
.dispatch-icon { font-size: 16px; }
|
||||
|
||||
/* 모달 알람 메시지 (하단) */
|
||||
.modal-alarm-message {
|
||||
margin: 0 16px 12px;
|
||||
padding: 12px;
|
||||
background: rgba(90, 16, 44, 0.4);
|
||||
border-left: 4px solid #ff4f9a;
|
||||
}
|
||||
.alarm-msg-label {
|
||||
font-size: 10px; font-weight: 700;
|
||||
color: #ff4f9a; letter-spacing: 1.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.alarm-msg-text {
|
||||
font-size: 12px; color: var(--text);
|
||||
white-space: pre-wrap; line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 모달 푸터 */
|
||||
.modal-foot {
|
||||
display: flex; gap: 8px; padding: 10px 16px;
|
||||
border-top: 1px solid #2a3f5a;
|
||||
background: var(--bg-darker);
|
||||
}
|
||||
.modal-btn {
|
||||
padding: 10px 16px; font-size: 12px; font-weight: 700;
|
||||
cursor: pointer; letter-spacing: 1px;
|
||||
background: var(--bg-darker); color: var(--text);
|
||||
border: 1px solid var(--stroke);
|
||||
}
|
||||
.modal-btn-skip { color: var(--text-muted); }
|
||||
.modal-btn-skip:hover { color: var(--text); border-color: #fff; }
|
||||
.modal-btn-ack {
|
||||
margin-left: auto;
|
||||
background: var(--idle); color: var(--text-dim); border-color: var(--idle);
|
||||
}
|
||||
.modal-btn-ack:enabled {
|
||||
background: var(--active); color: #000; border-color: var(--active);
|
||||
animation: pulse-alarm 1.2s infinite;
|
||||
}
|
||||
.modal-btn-ack:enabled:hover { background: #5fff20; }
|
||||
.modal-btn-ack:disabled { cursor: not-allowed; }
|
||||
@@ -0,0 +1,562 @@
|
||||
# INVYONE Stage-2 — 개발 진행 상황
|
||||
|
||||
> 마지막 업데이트: 2026-04-30
|
||||
> 목적: SI FLEX 베트남법인 EHS 방재센터 입찰 PT (5/8 14:00) 시연용 디지털 트윈 데모
|
||||
> 위치: `C:\Users\defaultuser0\Downloads\invyone-stage2-demo-v1`
|
||||
|
||||
---
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
### 1.1 무엇을 만들고 있는가
|
||||
수처리 플랜트 (전처리 → UF → RO → MBR → 후처리) SCADA 디지털 트윈 데모.
|
||||
- **목표**: PT에서 발표자가 자유롭게 클릭하며 "이 시스템이 어떻게 제어되는지" 시각적으로 보여주는 인터랙티브 시연
|
||||
- **철학**: 정해진 시나리오를 따라가는 데모 ❌ / 샌드박스형 자유 제어 데모 ⭕
|
||||
- 어떤 펌프든, 밸브든, 탱크든 클릭하면 즉시 반응
|
||||
- 250ms tick 루프가 매 순간 흐름·압력·알람을 자동 재계산
|
||||
|
||||
### 1.2 참고 자료
|
||||
- 실제 사진: `C:\Users\defaultuser0\Downloads\참고\` 안 PDF/Excel
|
||||
- 시작 자산: `C:\Users\defaultuser0\Downloads\scada-svg-kit-v1\` (SVG 부품 라이브러리)
|
||||
- 추가 자산: `C:\Users\defaultuser0\Downloads\invyone-extra-svg\` (압력계, 카본 카트리지)
|
||||
- Stage-1 데모: `C:\Users\defaultuser0\Downloads\invyone-stage1-demo-v1\` (런타임 패턴 참고)
|
||||
|
||||
---
|
||||
|
||||
## 2. 파일 구조
|
||||
|
||||
```
|
||||
invyone-stage2-demo-v1/
|
||||
├── index.html 메인 페이지 (인라인 SVG 캔버스)
|
||||
├── css/
|
||||
│ └── invyone-stage2.css SCADA 다크 테마 + 애니메이션
|
||||
├── js/
|
||||
│ ├── components.js SVG 컴포넌트 템플릿 라이브러리
|
||||
│ ├── topology.js 플랜트 노드 정의 + 포트 + Anchor + 자동 라우팅
|
||||
│ ├── engine.js state + tick + 흐름/압력/알람 시뮬레이션
|
||||
│ ├── ui.js 클릭 위임 + 인스펙터 + 모드 버튼
|
||||
│ └── main.js 부트스트랩
|
||||
├── svg/
|
||||
│ ├── pressure-gauge.svg (참조용 원본)
|
||||
│ └── carbon-cartridge-bank.svg (참조용 원본)
|
||||
└── docs/
|
||||
├── README.md 사용자 가이드
|
||||
└── PROGRESS.md ← 이 파일
|
||||
```
|
||||
|
||||
### 2.1 파일별 역할 요약
|
||||
|
||||
#### `index.html` (109 lines)
|
||||
- 인라인 SVG 캔버스 (`viewBox="0 0 2000 880"`, preserveAspectRatio xMidYMid meet)
|
||||
- 글로벌 SVG `<defs>`: metal-h, metal-v, metal-tank, glass-grad 그라디언트
|
||||
- 사이드바: 모드 버튼 (NORMAL/BACKWASH/CIP/STOP/+CHEM-DOSE), Reset/Pause/Alarm/Flow Multiplier, Inspector
|
||||
- 하단: Event Log 영역
|
||||
- 5개 JS 파일을 순서대로 로드: components → topology → engine → ui → main
|
||||
|
||||
#### `css/invyone-stage2.css` (~280 lines)
|
||||
- SCADA 다크 테마 변수: `--scada-bg`, `--scada-flow`, `--scada-active`, `--scada-warning`, `--scada-alarm` 등
|
||||
- 컴포넌트 상태 클래스:
|
||||
- `.comp.comp-pump.running .pump-impeller` → 회전 애니메이션 (0.8s linear infinite)
|
||||
- `.comp.comp-valve.closed .valve-handle` → 90° 회전
|
||||
- `.flow.active` → stroke-dashoffset march (0.6s)
|
||||
- `.flow.blocked` → 빨간 깜빡임
|
||||
- `.comp.comp-tank.alarm-hh/.alarm-ll` → 펄스 알람
|
||||
- `.comp-module.active .cell-fluid` → fluidPulse (1.4s, opacity + scale)
|
||||
- `.comp-tank-mbr.aerating` → MBR 외곽 청록 발광 (aerationGlow)
|
||||
- `.mbr-bubbles .bubble` → 상승 + 페이드 (bubbleRise, 2.4s)
|
||||
|
||||
#### `js/components.js` (~400 lines)
|
||||
SVG 컴포넌트 템플릿 함수 라이브러리. 각 함수는 SVG 문자열을 반환.
|
||||
- `COMP.tank` — 큰 사각 탱크 (RAW, WATER FILTER, DP, REGULATING) — **측면 스케일 눈금 0/25/50/75/100 추가됨**
|
||||
- `COMP.dosingTank` — 작은 도징 탱크 (CHEM, CEP-1/2, CIP TANK)
|
||||
- `COMP.acTank` — SAND/AC 사일로 (콘 상단 + 솔리드 스커트 + 하단 outlet stub) — **A자 다리 제거, 솔리드 베이스로 변경됨**
|
||||
- `COMP.pump` — 가로 원심 펌프 (좌측 인입, 우측 토출) — **모터 쿨링 핀 4개 + 흐름 방향 화살표 추가됨**
|
||||
- `COMP.pumpV` — **세로 원심 펌프 (상단 인입, 우측 토출)** — SAND/AC/CHEM 펌프용 (탱크 바로 아래 정렬)
|
||||
- `COMP.airBlower` — 에어 블로어 — **4 곡선 잎사귀 블레이드 (X자 → 진짜 fan 모양)**, LED 모터 본체 안쪽으로 이동
|
||||
- `COMP.valve` — **자체 파이프 rect 제거** (하부 메인 파이프의 흐름이 밸브 영역에서 끊기지 않게). 본체 + 핸들 + flow-l/flow-r만
|
||||
- `COMP.pipe` — **rect 기반 직각 덕트 + 자동 갭/점프 아치** (P&ID 표준 crossing 표시)
|
||||
- `points` 배열 받아서 segments + corners 자동 생성
|
||||
- `gaps` 배열 받으면 해당 위치에 18px 갭 + 곡선 점프 아치
|
||||
- flow line은 path with 반원 호 (A 명령)로 갭 위로 우회
|
||||
- `COMP.pressureGauge` — 아날로그 다이얼 (0-max), 정상/주의/알람 3구간 호, 회전 바늘
|
||||
- `COMP.sensor` — DO/Temp/TSS/pH 등 디지털 readout
|
||||
- `COMP.led` — 작은 상태 LED
|
||||
- `COMP.filter` — 단순 흰색 필터 통 (% 텍스트 없음)
|
||||
- `COMP.membraneBank` — UF/RO 멤브레인 뱅크
|
||||
- `orientation: 'vertical'` → 5개 세로 술병 (UF) — 22w × 130h, 적색 캡 상하
|
||||
- `orientation: 'horizontal'` → 5개 가로 원통 적층 (RO) — 180w × 16h
|
||||
- `COMP.carbonBank` — MBR 카본 카트리지 5개 (배경 투명 → MBR 액체 비침)
|
||||
- `COMP.statusTile` — KPI 패널 (FLOW IN/OUT, RECOVERY, ACTIVE ALARMS)
|
||||
- `COMP.panel` — 점선 외곽 박스 (CEP SYSTEM, CIP SYSTEM)
|
||||
|
||||
#### `js/topology.js` (~480 lines) — 핵심 파일
|
||||
플랜트 위상학 + Anchor 시스템 + 자동 파이프 라우팅 + 교차 감지.
|
||||
|
||||
**상수 배열** (TANKS, PUMPS, VALVES, FILTERS, MODULES, GAUGES, SENSORS, LEDS, TILES, PANELS, PIPES)
|
||||
|
||||
**Anchor 시스템** (자세한 건 §3 참조):
|
||||
- `getPortAbsPos(comp, portName)` — 컴포넌트 타입별 포트 절대좌표 계산
|
||||
- `tagTypes()` — 각 컴포넌트에 `_type` 부여
|
||||
- `pipePointAt(points, fraction)` — 직각 폴리라인의 fraction 지점 계산
|
||||
- `resolveAnchors()` — 멀티패스 (최대 12회) Port-anchor 해결
|
||||
- `resolveOnPipeAnchors(resolvedPipes)` — 파이프 위 컴포넌트 배치
|
||||
|
||||
**파이프 라우팅**:
|
||||
- `resolveRef(ref, ports)` — `'comp.port'` 문자열을 절대 좌표로
|
||||
- `autoRoute(from, to, via, hint, ports)` — from/to/via 받아서 직각 폴리라인 생성
|
||||
- `resolvePipe(spec, ports)` — 파이프 spec → points 배열
|
||||
- `detectCrossings(resolvedPipes)` — 모든 파이프 쌍 검사하여 직각 교차점 찾기
|
||||
|
||||
**buildScene()** — 모든 걸 묶는 메인 함수:
|
||||
1. `resolveAnchors()` — Port-anchor 해결 (탱크→펌프→필터→...)
|
||||
2. `computePorts()` — 모든 컴포넌트 포트 계산
|
||||
3. PIPES.map → `resolvePipe` (1차 라우팅)
|
||||
4. `resolveOnPipeAnchors(resolvedPipes)` — 파이프 위 valve/gauge/sensor 배치
|
||||
5. `computePorts()` 재계산 + 파이프 재해결 (앞 단계에서 이동된 컴포넌트 반영)
|
||||
6. `detectCrossings(resolvedPipes)` — 파이프 갭 위치 결정
|
||||
7. SVG 출력 순서: PANELS → PIPES → TANKS → FILTERS → MODULES → PUMPS → VALVES → SENSORS → GAUGES → LEDS → TILES
|
||||
|
||||
#### `js/engine.js` (~450 lines)
|
||||
- `state` 객체 — tanks, pumps, valves, sensors, gauges, cartridges, events, paused, flowMultiplier, currentMode
|
||||
- `init()` — TOPOLOGY 배열에서 state 초기화 + 모든 visual 함수 한 번씩 호출
|
||||
- `tick()` — 250ms 마다 호출:
|
||||
- 각 활성 펌프에 대해 source → dest 액체 이동 (LPM 기반)
|
||||
- 게이지 값 업데이트 (펌프 운전 상태에 따라)
|
||||
- 센서 값 잔잔한 노이즈 추가
|
||||
- 모든 visual 갱신
|
||||
- 타일 갱신 (Flow In/Out, Recovery, Alarms)
|
||||
- `updateRoutes()` — 모든 .flow 클래스 비활성화 후, 활성 펌프의 pipes/valves에 .active 부여
|
||||
- **모듈 활성**: uf-system은 uf-pump-a/b 운전 시, mbr-carb-l/r는 ro-press 또는 aer-drain 운전 시
|
||||
- **MBR aeration**: air-blower 운전 시 mbr 컴포넌트에 .aerating 클래스
|
||||
- 비주얼 함수:
|
||||
- `setTankVisual(id)` — 액체 높이/% 텍스트/알람 색상
|
||||
- `setPumpVisual(id)` — .running 클래스 + LED
|
||||
- `setValveVisual(id)` — .open/.closed 클래스 (핸들 회전 CSS)
|
||||
- `setSensorVisual(id)` — 값 + 알람
|
||||
- `setGaugeVisual(id)` — 바늘 회전 + 디지털 readout
|
||||
- 액션:
|
||||
- `togglePump(id)`, `toggleValve(id)`, `setTankLevel(id, pct)`, `setSensor(id, val)`, `setRpm(id, rpm)`, `isolateCartridge(cid)`
|
||||
- `applyMode(mode)` — NORMAL/BACKWASH/CIP/STOP/CHEM-DOSE 프리셋
|
||||
- `triggerDemoAlarm()` — RAW HH + WF LL + pH HI 동시 트리거
|
||||
- `reset()` — 초기 상태 복구
|
||||
|
||||
#### `js/ui.js` (~240 lines)
|
||||
- `wireSceneClicks()` — SVG에 단일 클릭 핸들러:
|
||||
- 카트리지 클릭 → isolateCartridge
|
||||
- 일반 컴포넌트 클릭 → select() + 펌프/밸브면 토글
|
||||
- `renderInspector()` — 선택된 컴포넌트의 상세 패널 (탱크는 슬라이더, 펌프는 RPM, 센서는 값 입력 등)
|
||||
- `wireSidebarButtons()` — 모드 버튼 (NORMAL/BACKWASH/CIP/STOP/+CHEM-DOSE)
|
||||
- `wireGlobalControls()` — Reset, Pause, Alarm, Flow Multiplier, Clear Log, Deselect
|
||||
- `tickClock()` — 1초 주기 시계 갱신
|
||||
|
||||
#### `js/main.js` (~40 lines)
|
||||
부트스트랩. DOMContentLoaded에서:
|
||||
1. `INVYONE_TOPO.buildScene()` 호출 → SVG 문자열 받아서 `#scene-content`에 innerHTML
|
||||
2. `INVYONE_ENGINE.init()`
|
||||
3. `INVYONE_UI.init()`
|
||||
4. `INVYONE_ENGINE.start()` → tick 시작
|
||||
5. `window.INVYONE` 노출 (브라우저 콘솔 디버그용)
|
||||
|
||||
---
|
||||
|
||||
## 3. **Anchor 시스템** (이 프로젝트의 핵심)
|
||||
|
||||
### 3.1 왜 만들었나
|
||||
초기엔 모든 컴포넌트가 hardcoded `x, y` 좌표를 가졌음. 결과:
|
||||
- 컴포넌트 하나 옮기면 연결된 파이프 좌표 다 다시 손봐야 함
|
||||
- 미세 어긋남이 누적되며 시각적 품질 저하
|
||||
- 사용자 피드백: "타입별로 입구 다 했는데 왜 아직도 어긋나냐"
|
||||
|
||||
→ **Port-anchor + onPipe-anchor 시스템 도입**으로 해결.
|
||||
|
||||
### 3.2 두 가지 anchor 종류
|
||||
|
||||
#### A. Port-anchor (포트 기반)
|
||||
```js
|
||||
{ id: 'sand-out', anchor: { to: 'sand.bot', myPort: 'in', dy: 14 }, ... }
|
||||
```
|
||||
- `to: 'comp.port'` — 부모 컴포넌트의 어느 포트에 정렬할지
|
||||
- `myPort: 'X'` — 내 어느 포트가 거기에 맞붙을지
|
||||
- `dx, dy` — 추가 오프셋
|
||||
|
||||
해석: "내 `in` 포트가 sand의 `bot` 포트보다 14px 아래에 위치"
|
||||
|
||||
#### B. onPipe-anchor (파이프 위)
|
||||
```js
|
||||
{ id: 'v-input', anchor: { onPipe: 'p-bwa1-pf', at: 0.30 }, ... }
|
||||
```
|
||||
- `onPipe: 'pipe-id'` — 어느 파이프 위에 놓을지
|
||||
- `at: 0..1` — 파이프 길이의 fraction
|
||||
- `myPort: 'center'` (기본) — 내 어느 포트가 그 지점에 정렬될지
|
||||
- `dx, dy` — 추가 오프셋
|
||||
|
||||
해석: "p-bwa1-pf 파이프의 30% 지점에 내 center를 놓음"
|
||||
|
||||
### 3.3 어떻게 동작하나
|
||||
1. **getPortAbsPos(comp, port)** — 컴포넌트 타입(`tank`, `ac`, `dosing`, `mbr`, `pump`, `pumpV`, `airBlower`, `valve`, `filter`, `gauge`, `sensor`, `led`, `membraneV`, `membraneH`, `carbon`)별로 포트 좌표 계산
|
||||
2. **tagTypes()** — TANKS/PUMPS/VALVES/etc. 배열의 모든 컴포넌트에 `_type` 부여
|
||||
3. **resolveAnchors()** — 멀티패스 (최대 12회):
|
||||
- 각 컴포넌트의 anchor 검사
|
||||
- 부모(`anchor.to`)가 이미 resolved라면 해당 포트 위치 가져와서 내 위치 계산
|
||||
- 부모가 아직 resolved 아니면 다음 패스에서 재시도
|
||||
- 의존성 그래프 자동 해결
|
||||
4. **resolveOnPipeAnchors(resolvedPipes)** — 파이프 라우팅이 끝난 후, onPipe-anchor 컴포넌트들을 파이프 위에 배치
|
||||
|
||||
### 3.4 root component (절대 좌표 유지)
|
||||
모든 anchor 시스템은 어딘가 시작점이 필요하므로 **root**는 절대 좌표 유지:
|
||||
| 카테고리 | root 컴포넌트 | 이유 |
|
||||
|---|---|---|
|
||||
| TANKS | 11개 모두 (sand, ac, raw, chem, water-filter, chem-cep-1, chem-cep-2, cip-tank, mbr, dp, regulating) | 레이아웃 고정 위치 |
|
||||
| PUMPS | bw-a1, air-blower, ro-feed | 시스템 입구/독립 컴포넌트 (자연스러운 부모 없음) |
|
||||
| TILES | 4개 (FLOW IN/OUT/RECOVERY/ALARMS) | 우측 사이드바 UI |
|
||||
| PANELS | 3개 (cep, cip, mbr-frame) | 외곽 데코레이션 |
|
||||
|
||||
총 **21개 root**. 나머지 **47개 컴포넌트**는 모두 anchor로 자동 위치 계산.
|
||||
|
||||
### 3.5 좌표 사용 통계 (현재)
|
||||
|
||||
| 카테고리 | 총 | hardcoded | anchor |
|
||||
|---|---|---|---|
|
||||
| 탱크 | 11 | 11 (root) | 0 |
|
||||
| 펌프 | 13 | 3 (root) | **10** |
|
||||
| 밸브 | 12 | 0 | **12 (전부 onPipe)** |
|
||||
| 필터 | 4 | 0 | **4** |
|
||||
| 모듈 (UF/RO/Carbon) | 4 | 0 | **4** |
|
||||
| 게이지 | 11 | 0 | **11** |
|
||||
| 센서 | 5 | 0 | **5** |
|
||||
| LED | 2 | 0 | **1** + 1 root |
|
||||
| 타일 | 4 | 4 (UI) | 0 |
|
||||
| 패널 | 3 | 3 (deco) | 0 |
|
||||
| **합계** | **69** | **21** | **47** |
|
||||
|
||||
### 3.6 사용 예시 (실제 topology 코드)
|
||||
```js
|
||||
// 탱크는 root
|
||||
{ id: 'sand', x: 340, y: 120, w: 96, h: 150, type: 'ac', ... },
|
||||
|
||||
// 펌프는 탱크에 anchor → 탱크 옮기면 펌프 자동 따라감
|
||||
{ id: 'sand-out', anchor: { to: 'sand.bot', myPort: 'in', dy: 14 }, orient: 'v', ... },
|
||||
|
||||
// 다음 펌프는 이전 펌프에 anchor (체인)
|
||||
{ id: 'uf-pump-a', anchor: { to: 'bw-a2.out', myPort: 'in', dx: 120 }, ... },
|
||||
{ id: 'uf-pump-b', anchor: { to: 'uf-pump-a.bot', myPort: 'top', dy: 12 }, ... },
|
||||
|
||||
// 필터는 펌프 토출에 anchor
|
||||
{ id: 'pump-filter', anchor: { to: 'bw-a1.out', myPort: 'left', dx: 107 }, ... },
|
||||
|
||||
// 밸브는 파이프 위에 anchor → 파이프 라우팅 바뀌면 밸브 자동 이동
|
||||
{ id: 'v-input', anchor: { onPipe: 'p-bwa1-pf', at: 0.30 }, ... },
|
||||
{ id: 'v-uf-in', anchor: { onPipe: 'p-bw2-uf', at: 0.75 }, ... },
|
||||
|
||||
// 게이지는 펌프에 anchor → 펌프 옮기면 게이지 자동 따라감
|
||||
{ id: 'p-in-1', anchor: { to: 'bw-a1.top', myPort: 'center', dx: -23, dy: -110 }, ... },
|
||||
|
||||
// 센서는 MBR에 anchor (sensor row)
|
||||
{ id: 'do', anchor: { to: 'mbr.top', myPort: 'topLeft', dx: -250, dy: -80 }, ... },
|
||||
{ id: 'temp', anchor: { to: 'mbr.top', myPort: 'topLeft', dx: -130, dy: -80 }, ... },
|
||||
|
||||
// 모듈 (UF/RO/Carbon)은 인접 컴포넌트에 anchor
|
||||
{ id: 'uf-system', anchor: { to: 'uf-filter.right', myPort: 'topLeft', dx: 62, dy: -122 }, ... },
|
||||
{ id: 'mbr-carb-l', anchor: { to: 'mbr.top', myPort: 'topLeft', dx: -183, dy: 106 }, ... },
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 파이프 시스템
|
||||
|
||||
### 4.1 파이프 spec 형식
|
||||
```js
|
||||
// 단순 (자동 라우팅 + 직각 corner 자동 삽입)
|
||||
{ id: 'p-bw2-uf', from: 'bw-a2.out', to: 'uf-pump-a.in' },
|
||||
|
||||
// via 웨이포인트 지정
|
||||
{ id: 'p-ac-raw', from: 'ac-out.out', to: 'raw.top', via: [{ x: 605 }, { y: 90 }] },
|
||||
|
||||
// hint로 첫 corner 방향 제어
|
||||
{ id: 'p-chem-uf', from: 'chem.bot', to: 'chem-uf.in', hint: 'v-first' },
|
||||
```
|
||||
|
||||
**spec 필드**:
|
||||
- `from`, `to`: `'comp.port'` 문자열 또는 `{x, y}` 객체
|
||||
- `via`: 웨이포인트 배열. 각 항목 `{x, y}`, `{x: only}`, `{y: only}`, 또는 `'comp.port'` 문자열
|
||||
- 부분 좌표(`{x: only}`)는 직전 위치의 다른 축을 유지
|
||||
- `hint`: `'h-first'` (기본) 또는 `'v-first'` — 마지막 corner 방향
|
||||
- `points`: 명시적 절대 좌표 배열 (사용 시 from/to/via/hint 무시)
|
||||
|
||||
### 4.2 자동 corner 삽입
|
||||
`autoRoute(from, to, via, hint, ports)`:
|
||||
- 각 웨이포인트 사이에 직각만 사용
|
||||
- 직전과 다음이 둘 다 다른 축이면 implicit corner 추가
|
||||
- 마지막 to까지의 corner는 hint로 결정
|
||||
|
||||
예: `from: (100, 50)`, `via: [{x: 200, y: 100}]`, `to: (300, 200)`
|
||||
- (100,50) → corner (100,100) → (200,100) → corner (200, ...). hint h-first → corner (300, 100) → (300, 200)
|
||||
- 최종: [(100,50), (100,100), (200,100), (300,100), (300,200)]
|
||||
|
||||
### 4.3 파이프 교차 자동 감지 (P&ID 표준 점프)
|
||||
`detectCrossings(resolvedPipes)`:
|
||||
- 모든 파이프 쌍에 대해 모든 segment 쌍 검사
|
||||
- 한쪽이 수직, 다른쪽이 수평이고 정확히 한 점에서 교차하면 crossing 등록
|
||||
- **세로 파이프가 가로 파이프 위로 점프** (vertical gets gap)
|
||||
|
||||
`COMP.pipe`의 갭 처리:
|
||||
- `gaps: [{x, y}, ...]` 배열 받음
|
||||
- 해당 위치에서 18px (GH × 2) 갭 생성
|
||||
- 갭 위에 곡선 점프 아치 (메탈 그라디언트) 그림
|
||||
- flow line은 path with 반원 호 (A 명령)로 갭 위를 우회
|
||||
|
||||
현재 검출되는 교차:
|
||||
- `p-chem-uf` (세로) ↔ `p-bw2-uf` (가로) — 챔 주입 라인이 BW-B→UF 라인 위로 점프
|
||||
|
||||
---
|
||||
|
||||
## 5. 시각 정교화 항목 (정교한 디테일들)
|
||||
|
||||
### 5.1 컴포넌트 디테일
|
||||
| 컴포넌트 | 디테일 |
|
||||
|---|---|
|
||||
| **큰 탱크** | 측면에 0/25/50/75/100 메이저 틱, 25/75 미디엄 틱, 10/20/30/40/60/70/80/90 마이너 틱 |
|
||||
| **AC/SAND 사일로** | 콘 상단 (22px) + 솔리드 스커트 베이스 (8px) + 하단 6px outlet stub |
|
||||
| **가로 펌프** | 모터에 4개 쿨링 핀 + 펌프 하단 흐름 방향 화살표 |
|
||||
| **세로 펌프 (pumpV)** | 상단 인입 + 모터(쿨링핀 5개) + 중앙 임펠러 + 우측 토출 + 하단 LED |
|
||||
| **에어 블로어** | 4개 곡선 잎사귀 블레이드 (부드러운 fan 모양), 모터 본체 안 작은 LED |
|
||||
| **밸브** | 자체 파이프 rect 제거 → 본체 파이프 흐름 끊기지 않음. 핸들 90° 회전 표시 |
|
||||
| **압력계** | 0~max 다이얼, 정상(녹)/주의(주황)/알람(분홍) 3구간 호, 회전 바늘 + 디지털 readout |
|
||||
| **UF SYSTEM** | 5개 세로 술병 (적색 캡 상하), 활성 시 fluidPulse 애니메이션 |
|
||||
| **RO SYSTEM** | 5개 가로 원통 적층, 활성 시 fluidPulse |
|
||||
| **MBR 카본** | 5개 세로 카트리지 × 2뱅크, 배경 투명 (MBR 액체 비침), 활성 시 펄스 |
|
||||
| **MBR** | 외곽 청록 발광 (aerating 시), 7개 버블 상승 + 페이드 |
|
||||
| **필터** | 단순 흰색 통, 내부 % 텍스트 없음 |
|
||||
| **파이프** | rect 기반 직각 덕트 (메탈 + 어두운 채널) + flow line 애니 |
|
||||
| **파이프 교차** | 세로 파이프 갭 + 곡선 점프 아치 (P&ID 표준) |
|
||||
|
||||
### 5.2 활성 상태 표시
|
||||
| 상태 | 시각 |
|
||||
|---|---|
|
||||
| 펌프 ON | 임펠러 회전 (0.8s linear) + LED 녹색 |
|
||||
| 펌프 OFF | 정지 + LED 어둠 |
|
||||
| 밸브 OPEN | 핸들 가로 (기본) + 본체 어두운 녹색 |
|
||||
| 밸브 CLOSED | 핸들 90° 회전 (세로) + 본체 어둠 |
|
||||
| 흐름 활성 | dash 행진 애니 (0.6s linear) |
|
||||
| 흐름 차단 | 빨간 깜빡임 |
|
||||
| 탱크 알람 HH/LL | 레벨박스 빨강 펄스 |
|
||||
| 탱크 알람 HL/LH | 레벨박스 주황 펄스 |
|
||||
| 모듈 활성 | cell-fluid 펄스 (opacity + scaleY) + 매니폴드 라인 굵게 + 청록 발광 |
|
||||
| MBR 폭기 중 | 외곽 청록 발광 + 7개 버블 상승 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 엔진 (시뮬레이션) 모델
|
||||
|
||||
### 6.1 흐름 모델
|
||||
- 매 250ms tick:
|
||||
- 각 펌프에 대해 `pumpRouteActive(p)` 검사
|
||||
- 활성 조건: 펌프 ON + 모든 valves OPEN + source 탱크 잔량 > 0 + dest 탱크 여유 > 0
|
||||
- 활성 시: `LPM × (RPM/1450) × flowMultiplier × (250/60000)` 리터를 source에서 차감 / dest에 적립
|
||||
|
||||
### 6.2 압력 모델 (간단 휴리스틱)
|
||||
- 각 게이지는 `source` 펌프와 연결
|
||||
- 펌프 ON + 경로 active → 게이지 값 약 55% × max
|
||||
- 펌프 ON + 경로 blocked → 약 85% × max (압력 급상승)
|
||||
- 펌프 OFF → 0
|
||||
- smoothing: `value = value * 0.85 + target * 0.15`
|
||||
|
||||
### 6.3 알람 임계
|
||||
| 항목 | 알람 |
|
||||
|---|---|
|
||||
| 탱크 수위 | HH (≥95%) / HL (≥85%) / LH (≤5%) / LL (≤20%) |
|
||||
| 센서 (DO/Temp/TSS/pH) | 각 센서별 lo/hi 임계 (예: pH 6.5~8.5 정상) |
|
||||
| 압력계 | 75% 이상 빨간 배경 |
|
||||
|
||||
### 6.4 운전 모드 프리셋
|
||||
| 모드 | 동작 |
|
||||
|---|---|
|
||||
| **NORMAL** | 모든 메인 밸브 OPEN, 모든 메인 펌프 ON (uf-pump-b, cip-pump, chem-uf 제외) |
|
||||
| **BACKWASH** | UF 라인만 ON (bw-a1, bw-a2 + V-IN, V-UF-IN, V-UF-OUT) |
|
||||
| **CIP** | CIP 라인 ON (cip-pump + v-cip-out, v-ro-in) |
|
||||
| **+CHEM-DOSE** | chem-uf 펌프 + v-chem-uf ON (UF에 케미컬 주입) |
|
||||
| **STOP** | 모두 OFF |
|
||||
|
||||
---
|
||||
|
||||
## 7. 현재 컴포넌트 카운트 (검증된 수치)
|
||||
|
||||
| 카테고리 | 개수 |
|
||||
|---|---|
|
||||
| 탱크 | 11 (sand, ac, raw, chem, water-filter, chem-cep-1, chem-cep-2, cip-tank, mbr, dp, regulating) |
|
||||
| 펌프 | 13 (bw-a1, sand-out, ac-out, bw-a2, chem-uf, uf-pump-a, uf-pump-b, ro-feed, ro-press, cip-pump, aer-drain, dp-out, air-blower) |
|
||||
| 밸브 | 12 (v-input, v-sand-out, v-ac-out, v-raw-out, v-chem-uf, v-uf-in, v-uf-out, v-ro-in, v-ro-press-out, v-cip-out, v-mbr-out, v-dp-out) |
|
||||
| 파이프 | 26 |
|
||||
| 필터 | 4 (pump-filter, uf-filter, ro-filter, cip-filter) |
|
||||
| 모듈 | 4 (uf-system, ro-system, mbr-carb-l, mbr-carb-r) |
|
||||
| 압력계 | 11 |
|
||||
| 센서 | 5 (do, temp, tss, ph, tss-raw) |
|
||||
| LED | 2 |
|
||||
| 타일 | 4 (FLOW IN/OUT, RECOVERY, ACTIVE ALARMS) |
|
||||
| 패널 | 3 (CEP, CIP, mbr-frame) |
|
||||
| **총** | **95** |
|
||||
|
||||
---
|
||||
|
||||
## 8. 개발 여정 (반복 학습)
|
||||
|
||||
### 8.1 Stage 1 — 좌표 기반 단순 배치 (실패)
|
||||
- 모든 컴포넌트 hardcoded x/y
|
||||
- 결과: 매번 어긋나고 사진과 안 맞음
|
||||
- 사용자 피드백: "엉망징창"
|
||||
|
||||
### 8.2 Stage 2 — 그리드 + 직각 파이프 (개선)
|
||||
- Codex와 협업으로 그리드 시스템 도입 (gx(n) = 40 + n*160)
|
||||
- 파이프를 stroke path → rect 세그먼트로 변경
|
||||
- 직각만 허용
|
||||
- 18개 파이프 모두 직각 라우팅
|
||||
- 결과: 향상되었지만 아직 어긋남
|
||||
|
||||
### 8.3 Stage 3 — 포트 기반 파이프 (개선)
|
||||
- 모든 파이프를 `'comp.port'` 형식으로
|
||||
- `autoRoute()` 함수로 직각 폴리라인 자동 생성
|
||||
- `via` 웨이포인트 지원
|
||||
- 결과: 파이프 라우팅 자체는 깔끔. 근데 컴포넌트 위치는 여전히 hardcoded
|
||||
|
||||
### 8.4 Stage 4 — UF 세로 / RO 가로 분기 (시각 정확성)
|
||||
- `membraneBank`에 `orientation` 파라미터 추가
|
||||
- UF=vertical (5 세로 술병), RO=horizontal (5 가로 원통)
|
||||
- 카본 뱅크 배경 투명화 (MBR 액체 비침)
|
||||
|
||||
### 8.5 Stage 5 — 정교화 (P&ID 표준)
|
||||
- 파이프 자동 교차 감지 + 점프 아치
|
||||
- 탱크 측면 스케일 눈금
|
||||
- 펌프 모터 쿨링 핀 + 흐름 방향 화살표
|
||||
- MBR 폭기 버블 애니메이션
|
||||
- AIR BLOWER 4개 곡선 잎사귀 블레이드
|
||||
- 밸브 자체 파이프 rect 제거 (본체 파이프 흐름 통과)
|
||||
- UF/MBR 활성 펄스 애니메이션
|
||||
- 세로 펌프 (pumpV) 컴포넌트 추가
|
||||
|
||||
### 8.6 Stage 6 — Anchor 시스템 (현재)
|
||||
- Port-anchor 도입 (`{ to: 'comp.port', myPort: 'X', dx, dy }`)
|
||||
- onPipe-anchor 도입 (`{ onPipe: 'pipe-id', at: 0.5 }`)
|
||||
- 47개 컴포넌트가 anchor로 자동 위치 계산
|
||||
- 21개 root만 hardcoded
|
||||
|
||||
---
|
||||
|
||||
## 9. 알려진 이슈 / 제한사항
|
||||
|
||||
### 9.1 시각적 잔여 이슈
|
||||
| 이슈 | 심각도 | 원인 | 해결책 |
|
||||
|---|---|---|---|
|
||||
| `p-wf-ro` 긴 가로 파이프 (1450px) | 중 | 구조적 (WF 우상, RO FEED 좌하) | CEP 위치 재배치 또는 캔버스 폭 축소 |
|
||||
| 일부 밸브가 탱크 벽에 살짝 겹침 | 약 | 의도된 P&ID 스타일 (탱크 outlet에 valve) | 그대로 둠 |
|
||||
| AERATION DRAIN이 MBR 우측 영역에 살짝 겹침 | 약 | 의도 (펌프가 탱크 outlet에 위치) | 그대로 둠 |
|
||||
| `p-raw-bw2` 파이프 짧아서 v-raw-out 약간 겹침 (4px) | 약 | 구조적 (RAW 우측에 BW-B 너무 가까움) | BW-B 더 우측으로 이동 또는 valve 더 작게 |
|
||||
|
||||
### 9.2 미구현/미완성
|
||||
- 실제 PLC/MQTT/OPC UA 연결 없음 (mock state)
|
||||
- 안전 인터록·권한 분리·이력관리(historian)·알람 ack 미구현
|
||||
- 압력 모델은 단순 휴리스틱 (실제 펌프 곡선·배관 마찰 무시)
|
||||
- 단일 페이지 (실 입찰 스코프는 폐수3 + 오수2 + 약품탱크실8 + 대기29기로 화면 분할 필요)
|
||||
- 한국어 일부 (i18n 미구현, 한·영·베트남어 토글 가치 있음)
|
||||
- 알람 ack 워크플로우 없음
|
||||
- 인증/권한 게이트 없음 (입찰 조건엔 알람 OFF 패스워드 필요)
|
||||
|
||||
### 9.3 알려진 예외 케이스
|
||||
- 컴포넌트가 anchor를 따라 계산할 때 CSS transform-origin이 SVG에서 일관되지 않을 수 있음 — 현재 `transform-box: fill-box; transform-origin: center;` 사용
|
||||
- `setGaugeVisual`은 SVG `transform="rotate(angle 40 40)"` 어트리뷰트 직접 설정 (CSS rotation 불안정해서)
|
||||
- `pipePointAt`은 Manhattan 길이 사용 (직각 파이프 가정)
|
||||
|
||||
---
|
||||
|
||||
## 10. 콘솔 디버그 API (브라우저 DevTools)
|
||||
|
||||
페이지 로드 후 브라우저 콘솔에서:
|
||||
|
||||
```js
|
||||
// 도움말
|
||||
INVYONE.help()
|
||||
|
||||
// 상태 객체 직접 접근
|
||||
INVYONE.engine.state // 전체 state
|
||||
INVYONE.engine.state.tanks // 모든 탱크
|
||||
INVYONE.engine.state.pumps // 모든 펌프
|
||||
|
||||
// 액션
|
||||
INVYONE.engine.applyMode('NORMAL') // 모드 변경
|
||||
INVYONE.engine.togglePump('uf-pump-a') // 펌프 토글
|
||||
INVYONE.engine.toggleValve('v-uf-in') // 밸브 토글
|
||||
INVYONE.engine.setTankLevel('raw', 90) // 탱크 수위
|
||||
INVYONE.engine.setSensor('ph', 9.5) // 센서 값
|
||||
INVYONE.engine.setRpm('uf-pump-a', 2000) // 펌프 RPM
|
||||
INVYONE.engine.triggerDemoAlarm() // 데모 알람
|
||||
INVYONE.engine.reset() // 초기화
|
||||
|
||||
// 토폴로지 검사
|
||||
INVYONE.topo.computePorts() // 모든 포트 좌표
|
||||
INVYONE.topo.resolvePipe(spec, ports) // 파이프 해결
|
||||
|
||||
// 강제 시뮬레이션 일시정지
|
||||
INVYONE.engine.state.paused = true
|
||||
|
||||
// 유량 배율
|
||||
INVYONE.engine.state.flowMultiplier = 20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 내일 이어서 할 작업 (TODO 우선순위)
|
||||
|
||||
### 11.1 Critical (PT 전 반드시)
|
||||
- [ ] 전체 페이지 레이아웃 시각 점검 (브라우저에서 한 번 더)
|
||||
- [ ] 모드 전환 동작 검증 (NORMAL→BACKWASH→CIP→STOP 시 흐름 변화 자연스러운지)
|
||||
- [ ] 알람 데모 동작 검증 (트리거 시 즉각 반응)
|
||||
- [ ] PT용 시연 시나리오 정리 (어떤 순서로 클릭/조작할지 스크립트)
|
||||
|
||||
### 11.2 High (가능하면)
|
||||
- [ ] 알람 ack 워크플로우 (알람 발생 → 사이드패널 ACK 버튼 → 해소 로그)
|
||||
- [ ] 알람 OFF 시 패스워드 게이트 (입찰 조건)
|
||||
- [ ] 시연 시나리오 자동 재생 모드 (옵션)
|
||||
- [ ] 트렌드 그래프 (탱크 수위/압력 시계열, mini sparkline)
|
||||
|
||||
### 11.3 Nice to have
|
||||
- [ ] i18n 토글 (한국어/영어/베트남어)
|
||||
- [ ] 다화면 라우터 (메인 + 약품탱크실 + 대기방지 시설)
|
||||
- [ ] PDF 보고서 생성 (이벤트 로그 → PDF)
|
||||
- [ ] PT 모드 (큰 글씨, 전체화면 자동)
|
||||
|
||||
### 11.4 잠재 리팩터링
|
||||
- [ ] root 컴포넌트도 grid 시스템 사용 (gx()/gy() 함수)
|
||||
- [ ] panels/tiles도 anchor 시스템에 포함
|
||||
- [ ] CSS animation 성능 최적화 (will-change, transform-only)
|
||||
|
||||
---
|
||||
|
||||
## 12. 빠르게 페이지 열기
|
||||
|
||||
1. 파일 탐색기에서 `C:\Users\defaultuser0\Downloads\invyone-stage2-demo-v1\index.html` 더블클릭
|
||||
2. 또는 브라우저에 `file:///C:/Users/defaultuser0/Downloads/invyone-stage2-demo-v1/index.html`
|
||||
3. 외부 의존성/빌드 단계 없음 — 바로 동작
|
||||
|
||||
> 캐시 문제 시: `Ctrl + Shift + R` 강제 새로고침
|
||||
|
||||
---
|
||||
|
||||
## 13. 핵심 요약 (한 줄씩)
|
||||
|
||||
- ✅ **포트 기반 파이프**: 모든 파이프가 `'comp.port'` 참조로 자동 라우팅
|
||||
- ✅ **Anchor 시스템**: 47개 컴포넌트가 자동 위치 계산, 21개 root만 hardcoded
|
||||
- ✅ **자동 직각 파이프**: `autoRoute(from, to, via, hint)` 함수로 직각 폴리라인 자동 생성
|
||||
- ✅ **자동 교차 감지 + P&ID 점프**: 파이프 교차 시 자동으로 갭 + 곡선 점프 아치
|
||||
- ✅ **세로 펌프 분기**: SAND/AC/CHEM 펌프가 탱크 바로 아래 정렬되는 vertical orientation
|
||||
- ✅ **UF/RO 분기**: UF=세로 술병, RO=가로 원통 (사진 일치)
|
||||
- ✅ **MBR 폭기 시각화**: AIR BLOWER ON 시 외곽 발광 + 버블 상승
|
||||
- ✅ **모듈 활성 펄스**: UF/RO/MBR-카본 운전 시 fluid pulse 애니메이션
|
||||
- ✅ **탱크 측면 스케일**: 0/25/50/75/100 눈금
|
||||
- ✅ **펌프 디테일**: 모터 쿨링 핀 + 흐름 방향 화살표
|
||||
- ✅ **밸브 무파이프**: 본체 파이프 흐름이 밸브 영역에서 끊기지 않게
|
||||
- ✅ **5단계 모드**: NORMAL/BACKWASH/CIP/STOP/+CHEM-DOSE
|
||||
- ✅ **알람 시스템**: 탱크 HH/HL/LH/LL + 센서 lo/hi + 압력 75%↑
|
||||
- ✅ **이벤트 로그**: 시간순 + 레벨별 색상
|
||||
- ✅ **인스펙터**: 클릭한 컴포넌트의 상세 컨트롤 (슬라이더 등)
|
||||
- ✅ **콘솔 API**: `INVYONE.engine.*` 으로 직접 제어 가능
|
||||
|
||||
PT까지 8일 남음. 핵심 기능은 완성. 내일은 시각 미세조정 + 시연 시나리오 정리.
|
||||
@@ -0,0 +1,115 @@
|
||||
# INVYONE Stage-2 — Water Treatment Digital Twin Demo
|
||||
|
||||
수처리 플랜트 SCADA 시연용 인터랙티브 디지털 트윈. 실제 PLC/DB 연결은 없는 **샌드박스형 시각 제어 데모**.
|
||||
|
||||
## 목적
|
||||
|
||||
- 입찰 PT에서 **"이 시스템이 시각적으로 어떻게 제어되는가"** 를 보여주는 용도
|
||||
- 정해진 시나리오를 따라가는 데모가 아니라, **발표자가 어디든 클릭/조작해도 자연스럽게 반응**하는 자유 제어형
|
||||
- 각 컴포넌트가 독립 제어 가능 + 흐름·압력·알람이 서로 연동되어 **시스템 레벨 제어 가능성**을 입증
|
||||
|
||||
## 실행
|
||||
|
||||
`index.html` 을 브라우저에서 열기. 외부 의존성·빌드 단계 없음.
|
||||
|
||||
## 구성
|
||||
|
||||
```
|
||||
invyone-stage2-demo-v1/
|
||||
├── index.html 메인 페이지 (인라인 SVG 캔버스)
|
||||
├── css/invyone-stage2.css SCADA 다크 테마 + 컴포넌트 상태 스타일
|
||||
├── js/
|
||||
│ ├── components.js SVG 컴포넌트 템플릿 (탱크/펌프/밸브/파이프/모듈/게이지/센서)
|
||||
│ ├── topology.js 플랜트 노드 정의 + 흐름 그래프 + buildScene()
|
||||
│ ├── engine.js state + tick loop + 흐름/압력/알람 시뮬레이션
|
||||
│ ├── ui.js 클릭 위임 + 사이드 패널 + 모드 버튼
|
||||
│ └── main.js 부트스트랩
|
||||
├── svg/
|
||||
│ ├── pressure-gauge.svg 아날로그 압력계 원본 (참조용)
|
||||
│ └── carbon-cartridge-bank.svg 카본 카트리지 뱅크 (C1~C5 개별 ID)
|
||||
└── docs/README.md
|
||||
```
|
||||
|
||||
## 인터랙션
|
||||
|
||||
| 액션 | 결과 |
|
||||
|---|---|
|
||||
| 펌프 클릭 | ON/OFF 토글, 임펠러 회전 시작/정지, LED 색 변경 |
|
||||
| 밸브 클릭 | OPEN/CLOSE 토글, 핸들 90° 회전 |
|
||||
| 탱크 클릭 | 사이드 패널에서 슬라이더로 수위 0~100% 조절 |
|
||||
| 센서 클릭 | 사이드 패널에서 값 직접 입력 (임계 초과 시 알람) |
|
||||
| 카트리지 클릭 | 격리(BYPASS) 토글 — 시각적으로 흐릿해지고 외곽 빨간 점선 |
|
||||
| 빈 영역 클릭 | 선택 해제 |
|
||||
|
||||
## 운전 모드 프리셋
|
||||
|
||||
상단 사이드바의 모드 버튼 하나로 밸브/펌프 조합을 한 번에 세팅:
|
||||
|
||||
- **NORMAL** — 정상 운전 (전처리 → UF → WF → RO → MBR → DP → 방류)
|
||||
- **BACKWASH** — 역세 (UF 라인만, 다른 라인 차단)
|
||||
- **CIP** — 화학 세정 (CIP 탱크 → RO 시스템 순환)
|
||||
- **STOP** — 전체 정지
|
||||
- **+ 약품 투입** — UF 인입에 케미컬 도징 ON
|
||||
|
||||
## 흐름 모델
|
||||
|
||||
각 펌프는 `source` 탱크에서 `dest` 탱크로 운반. 다음이 모두 충족되면 흐름이 활성:
|
||||
1. 펌프 ON
|
||||
2. 펌프 경로의 모든 밸브 OPEN
|
||||
3. source 탱크 잔량 > 0
|
||||
4. dest 탱크 여유 > 0
|
||||
|
||||
활성 시 매 250ms tick마다 `LPM × (RPM/1450) × flowMultiplier × (250/60000)` 리터를 source에서 차감 / dest에 적립. 흐름선(파이프 dash)은 active 클래스로 애니메이션.
|
||||
|
||||
**유량 배율** 슬라이더로 시연 속도 조절 (0.5x ~ 50x). 기본 5x.
|
||||
|
||||
## 알람
|
||||
|
||||
- **탱크**: HH(95%↑) / HL(85%↑) / LH(5↓) / LL(20↓) — % 박스 색·외곽 변경 + 이벤트 로그
|
||||
- **센서**: 각 센서별 lo/hi 임계 — 경계 초과 시 빨간 펄스
|
||||
- **압력계**: 75% 이상이면 배경 어두운 빨강
|
||||
- **데모 알람 버튼**: 한 클릭으로 RAW HH + WF LL + pH HI 동시 트리거
|
||||
|
||||
## Console API (브라우저 개발자도구)
|
||||
|
||||
```js
|
||||
INVYONE.help() // 사용법 출력
|
||||
INVYONE.engine.state // 전체 상태 객체
|
||||
INVYONE.engine.applyMode('NORMAL') // 모드 변경
|
||||
INVYONE.engine.togglePump('uf-pump-a') // 펌프 토글
|
||||
INVYONE.engine.toggleValve('v-uf-in') // 밸브 토글
|
||||
INVYONE.engine.setTankLevel('raw', 90) // 탱크 수위
|
||||
INVYONE.engine.setSensor('ph', 9.5) // 센서 값
|
||||
INVYONE.engine.triggerDemoAlarm() // 데모 알람 트리거
|
||||
INVYONE.engine.reset() // 초기화
|
||||
```
|
||||
|
||||
## 시연 가이드 (PT용 추천 흐름)
|
||||
|
||||
발표자가 즉흥적으로 어디든 시연 가능하지만, 다음 시퀀스가 임팩트 큼:
|
||||
|
||||
1. **시작**: STOP 상태에서 시작 → "현재 모든 라인 정지"
|
||||
2. **전체 가동**: NORMAL 클릭 → 모든 펌프 가동, 흐름 시작, 탱크 수위 변동
|
||||
3. **개별 제어**: UF 펌프 클릭으로 OFF → 그 라인만 흐름 멈춤
|
||||
4. **알람 시연**: ⚠ 알람 데모 클릭 → 탱크/센서 일괄 알람, 이벤트 로그 폭주
|
||||
5. **자유 조작**: 어떤 밸브든 닫아보기 → 즉시 경로 재계산되어 차단 표시
|
||||
6. **카트리지 격리**: MBR 카본 카트리지 하나 클릭 → 개별 부품 제어 가능 시연
|
||||
7. **모드 전환**: CIP → BACKWASH → NORMAL → 운전 모드 프리셋의 신속성
|
||||
8. **유량 가속**: 50x로 올림 → 탱크 수위 빠른 변동 → 시간축 압축 시연
|
||||
|
||||
## 한계 (PT용 데모 — 이후 단계로 분리)
|
||||
|
||||
- 실제 PLC/MQTT/OPC UA 데이터 채널 없음 (mock state)
|
||||
- 안전 인터록·권한 분리·이력관리(historian)·알람 ack 미구현
|
||||
- 압력 모델은 단순 휴리스틱 (실제 펌프 곡선·배관 마찰 무시)
|
||||
- 단일 페이지 (메인 화면만) — 실 입찰 스코프는 폐수3 + 오수2 + 약품탱크실8 + 대기29기로 화면 분할 필요
|
||||
- ID 컨벤션 단일 DOM 가정 (다중 인스턴스 시 prefix 필요)
|
||||
|
||||
## 다음 단계 (Stage-3 후보)
|
||||
|
||||
1. **다화면 라우터**: 메인 종합 + 약품탱크실 + 대기방지 시설 화면 분리 (각 화면을 같은 엔진 위에 올림)
|
||||
2. **WebSocket 데이터 채널**: mock 대신 실제 PLC/시뮬레이터 데이터 입력
|
||||
3. **이력 그래프**: 탱크 수위/압력 트렌드 차트 (Chart.js 같은 경량 라이브러리)
|
||||
4. **알람 ack 워크플로우**: 알람 발생 → 운영자 확인 → 해소 로그
|
||||
5. **사용자 권한**: 알람 OFF·중요 동작은 패스워드 게이트
|
||||
6. **i18n**: 한·영·베트남어 토글 (입찰처가 베트남법인이므로 베트남어 지원 가치 있음)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.4 MiB |
@@ -0,0 +1,353 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>INVYONE Stage-2 — Water Treatment SCADA Demo</title>
|
||||
<link rel="stylesheet" href="css/invyone-stage2.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<h1>INVYONE</h1>
|
||||
<span class="subtitle">Stage-2 · Water Treatment Digital Twin Demo</span>
|
||||
<span class="spacer"></span>
|
||||
<div class="mode-display"><span class="label">MODE</span><span id="current-mode">STOP</span></div>
|
||||
<div class="clock" id="clock">--:--:--</div>
|
||||
</header>
|
||||
|
||||
<!-- 시연용 상단 알람 배너 (평상시 숨김, 경고 시 슬라이드 다운) -->
|
||||
<div class="alarm-banner" id="alarm-banner">
|
||||
<span class="alarm-banner-icon">⚠</span>
|
||||
<span class="alarm-banner-severity">CRITICAL</span>
|
||||
<span class="alarm-banner-code" id="alarm-banner-code">P-IN-HH</span>
|
||||
<span class="alarm-banner-msg" id="alarm-banner-msg">BW-A1 토출 압력 — 정상범위(4.0~5.0 bar) 초과 / 누설 의심</span>
|
||||
<span class="alarm-banner-loc">
|
||||
<span class="loc-pin">📍</span>
|
||||
<b id="alarm-banner-loc-text">펌프룸 A · BW-A1 (원수 취수펌프 #1)</b>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<main class="main">
|
||||
|
||||
<div class="scene-wrap">
|
||||
<svg id="scene" viewBox="0 0 2000 880" preserveAspectRatio="xMidYMid meet">
|
||||
<defs>
|
||||
<linearGradient id="metal-h" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="0" y2="20">
|
||||
<stop offset="0" stop-color="#d9d9d9"/>
|
||||
<stop offset="0.18" stop-color="#f5f5f5"/>
|
||||
<stop offset="0.5" stop-color="#8f8f8f"/>
|
||||
<stop offset="0.82" stop-color="#d7d7d7"/>
|
||||
<stop offset="1" stop-color="#6f6f6f"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="metal-v" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="20" y2="0">
|
||||
<stop offset="0" stop-color="#d9d9d9"/>
|
||||
<stop offset="0.18" stop-color="#f5f5f5"/>
|
||||
<stop offset="0.5" stop-color="#8f8f8f"/>
|
||||
<stop offset="0.82" stop-color="#d7d7d7"/>
|
||||
<stop offset="1" stop-color="#6f6f6f"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="metal-tank" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="100" y2="0">
|
||||
<stop offset="0" stop-color="#666"/>
|
||||
<stop offset="0.18" stop-color="#ddd"/>
|
||||
<stop offset="0.42" stop-color="#fff"/>
|
||||
<stop offset="0.72" stop-color="#666"/>
|
||||
<stop offset="1" stop-color="#ddd"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="glass-grad" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="100" y2="0">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity="0.18"/>
|
||||
<stop offset="0.14" stop-color="#fff" stop-opacity="0.04"/>
|
||||
<stop offset="0.5" stop-color="#071326" stop-opacity="0.4"/>
|
||||
<stop offset="0.88" stop-color="#fff" stop-opacity="0.06"/>
|
||||
<stop offset="1" stop-color="#fff" stop-opacity="0.16"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="scene-content"></g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<aside class="sidebar">
|
||||
|
||||
<section class="sidebar-section">
|
||||
<h2>운전 모드</h2>
|
||||
<div class="modes">
|
||||
<button data-mode="NORMAL">NORMAL</button>
|
||||
<button data-mode="BACKWASH">BACKWASH</button>
|
||||
<button data-mode="CIP">CIP</button>
|
||||
<button data-mode="STOP" class="active">STOP</button>
|
||||
</div>
|
||||
<div class="controls-row">
|
||||
<button data-mode="CHEM-DOSE">+ 약품 투입</button>
|
||||
</div>
|
||||
<div class="controls-row">
|
||||
<button id="btn-reset">⟲ RESET</button>
|
||||
<button id="btn-pause">⏸ 일시정지</button>
|
||||
<button id="btn-alarm" class="warn">⚠ 알람 데모</button>
|
||||
</div>
|
||||
<div class="controls-row" style="margin-top:10px">
|
||||
<button id="btn-emergency" class="emergency">🚨 경고시스템 시연</button>
|
||||
</div>
|
||||
<div class="global-slider">
|
||||
<span>유량 배율</span>
|
||||
<input type="range" id="flow-multiplier" min="0.5" max="50" step="0.5" value="5" />
|
||||
<span id="flow-multiplier-val">5.0x</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sidebar-section inspector" id="inspector">
|
||||
<div class="inspector-empty">
|
||||
<div class="muted">컴포넌트를 클릭해 제어 패널을 여세요</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</aside>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- ============== EMERGENCY SCENARIO OVERLAY ============== -->
|
||||
<!-- 회사 맵 패널 (우측 별도, 평상시 화면 밖) -->
|
||||
<aside class="map-panel" id="map-panel">
|
||||
<header class="map-panel-head">
|
||||
<span class="map-panel-title">🗺 SI FLEX 베트남 사업장 맵</span>
|
||||
<span class="map-panel-status">실시간 위치 추적</span>
|
||||
</header>
|
||||
<div class="map-panel-body">
|
||||
<!-- 실제 사업장 평면도 반영 (SI FLEX 베트남) — 사용자 제공 디자인 (시안 테두리 + glow + 입구) -->
|
||||
<svg class="map-svg" viewBox="0 0 928 577" preserveAspectRatio="xMidYMid meet">
|
||||
<!-- 외곽 부지 (점선만, 라벨) -->
|
||||
<rect x="14" y="18" width="900" height="540" fill="none" stroke="#3a5278" stroke-width="1.2" stroke-dasharray="6 4"/>
|
||||
<text x="22" y="38" fill="#5af9ff" font-size="11" font-family="Arial">SITE BOUNDARY</text>
|
||||
|
||||
<!-- 좌상: 오·폐수처리장 (알람 위치) -->
|
||||
<g class="map-building" data-bid="pump-room-a">
|
||||
<rect x="30" y="42" width="160" height="78" fill="#0d1a2e" stroke="#5af9ff" stroke-width="1.5"/>
|
||||
<rect x="100" y="118" width="22" height="6" fill="#0d1a2e" stroke="#5af9ff" stroke-width="1"/>
|
||||
<text x="110" y="74" text-anchor="middle" fill="#fff" font-size="11" font-weight="600">오·폐수처리장</text>
|
||||
<text x="110" y="89" text-anchor="middle" fill="#cfd3d8" font-size="9">WASTEWATER</text>
|
||||
<text x="110" y="108" text-anchor="middle" fill="#ff8a3a" font-size="9">BW-A1 (원수 취수펌프)</text>
|
||||
</g>
|
||||
|
||||
<!-- 좌중상: SBG 종말처리장 -->
|
||||
<g class="map-building" data-bid="treatment">
|
||||
<rect x="30" y="135" width="160" height="78" fill="#0d1a2e" stroke="#5af9ff" stroke-width="1.5"/>
|
||||
<rect x="100" y="211" width="22" height="6" fill="#0d1a2e" stroke="#5af9ff" stroke-width="1"/>
|
||||
<text x="110" y="170" text-anchor="middle" fill="#fff" font-size="11" font-weight="600">SBG 종말처리장</text>
|
||||
<text x="110" y="186" text-anchor="middle" fill="#cfd3d8" font-size="9">SBG TREATMENT</text>
|
||||
</g>
|
||||
|
||||
<!-- 중상: 라벨 없는 큰 빈 영역 (우측 하단 입구 표시) -->
|
||||
<g>
|
||||
<rect x="210" y="42" width="440" height="171" fill="#0d1a2e" stroke="#5af9ff" stroke-width="1.5"/>
|
||||
<!-- 우측 하단 햄버거 입구 표시 -->
|
||||
<line x1="615" y1="200" x2="640" y2="200" stroke="#5af9ff" stroke-width="1"/>
|
||||
<line x1="615" y1="205" x2="640" y2="205" stroke="#5af9ff" stroke-width="1"/>
|
||||
<line x1="615" y1="210" x2="640" y2="210" stroke="#5af9ff" stroke-width="1"/>
|
||||
</g>
|
||||
|
||||
<!-- 우상 좌: 식당 -->
|
||||
<g class="map-building" data-bid="chemical">
|
||||
<rect x="665" y="60" width="125" height="100" fill="#0d1a2e" stroke="#5af9ff" stroke-width="1.5"/>
|
||||
<rect x="715" y="158" width="22" height="6" fill="#0d1a2e" stroke="#5af9ff" stroke-width="1"/>
|
||||
<text x="727" y="105" text-anchor="middle" fill="#fff" font-size="13" font-weight="600">식당</text>
|
||||
<text x="727" y="125" text-anchor="middle" fill="#cfd3d8" font-size="10">CAFETERIA</text>
|
||||
</g>
|
||||
|
||||
<!-- 우상 우: 방재센터 (담당자 시작) -->
|
||||
<g class="map-building" data-bid="control-room">
|
||||
<rect x="800" y="60" width="115" height="100" fill="#0d1a2e" stroke="#5af9ff" stroke-width="1.8"/>
|
||||
<rect x="845" y="158" width="22" height="6" fill="#0d1a2e" stroke="#5af9ff" stroke-width="1"/>
|
||||
<text x="857" y="100" text-anchor="middle" fill="#fff" font-size="12" font-weight="600">방재센터</text>
|
||||
<text x="857" y="115" text-anchor="middle" fill="#cfd3d8" font-size="9">SAFETY</text>
|
||||
<text x="857" y="128" text-anchor="middle" fill="#cfd3d8" font-size="9">CENTER</text>
|
||||
<text x="857" y="148" text-anchor="middle" fill="#7cff3a" font-size="9">● 24h</text>
|
||||
</g>
|
||||
|
||||
<!-- 좌하: 2공장 -->
|
||||
<g class="map-building" data-bid="factory-2">
|
||||
<rect x="30" y="240" width="475" height="305" fill="#0d1a2e" stroke="#5af9ff" stroke-width="1.5"/>
|
||||
<!-- 하단 입구 4개 -->
|
||||
<rect x="100" y="543" width="22" height="6" fill="#0d1a2e" stroke="#5af9ff" stroke-width="1"/>
|
||||
<rect x="200" y="543" width="22" height="6" fill="#0d1a2e" stroke="#5af9ff" stroke-width="1"/>
|
||||
<rect x="310" y="543" width="22" height="6" fill="#0d1a2e" stroke="#5af9ff" stroke-width="1"/>
|
||||
<rect x="420" y="543" width="22" height="6" fill="#0d1a2e" stroke="#5af9ff" stroke-width="1"/>
|
||||
<text x="267" y="390" text-anchor="middle" fill="#fff" font-size="20" font-weight="600">2공장</text>
|
||||
<text x="267" y="415" text-anchor="middle" fill="#cfd3d8" font-size="12">FACTORY 2</text>
|
||||
</g>
|
||||
|
||||
<!-- 중하: 사무동 (좁고 긴 박스) -->
|
||||
<g class="map-building" data-bid="office">
|
||||
<rect x="515" y="240" width="60" height="305" fill="#0d1a2e" stroke="#5af9ff" stroke-width="1.5"/>
|
||||
<rect x="535" y="455" width="20" height="20" fill="#0d1a2e" stroke="#5af9ff" stroke-width="1"/>
|
||||
<text x="545" y="388" text-anchor="middle" fill="#fff" font-size="11" font-weight="600">사무동</text>
|
||||
<text x="545" y="404" text-anchor="middle" fill="#cfd3d8" font-size="9">OFFICE BLDG</text>
|
||||
</g>
|
||||
|
||||
<!-- 우하: 1공장 -->
|
||||
<g class="map-building" data-bid="utility">
|
||||
<rect x="585" y="240" width="330" height="305" fill="#0d1a2e" stroke="#5af9ff" stroke-width="1.5"/>
|
||||
<!-- 하단 입구 2개 -->
|
||||
<rect x="660" y="543" width="22" height="6" fill="#0d1a2e" stroke="#5af9ff" stroke-width="1"/>
|
||||
<rect x="820" y="543" width="22" height="6" fill="#0d1a2e" stroke="#5af9ff" stroke-width="1"/>
|
||||
<text x="750" y="390" text-anchor="middle" fill="#fff" font-size="20" font-weight="600">1공장</text>
|
||||
<text x="750" y="415" text-anchor="middle" fill="#cfd3d8" font-size="12">FACTORY 1</text>
|
||||
</g>
|
||||
|
||||
<!-- 담당자 이동 경로 (방재센터 → 오·폐수처리장, 평상시 숨김) -->
|
||||
<path id="map-route" d="M 857 110 L 500 110 L 110 110" fill="none" stroke="#ff8a3a" stroke-width="2" stroke-dasharray="6 4" opacity="0"/>
|
||||
|
||||
<!-- 알람 위치 (오·폐수처리장 안, 평상시 숨김) -->
|
||||
<g id="map-alarm-pin" transform="translate(110 81)" opacity="0">
|
||||
<circle r="22" fill="#ff4f9a" opacity="0.25"/>
|
||||
<circle r="14" fill="#ff4f9a" opacity="0.5"/>
|
||||
<circle r="7" fill="#ff4f9a"/>
|
||||
<text y="-32" text-anchor="middle" fill="#ff4f9a" font-size="12" font-weight="700">★ ALARM</text>
|
||||
</g>
|
||||
|
||||
<!-- 담당자 아이콘 (평상시 방재센터 안) -->
|
||||
<g id="map-officer" transform="translate(857 110)">
|
||||
<circle r="14" fill="#5af9ff" stroke="#fff" stroke-width="2"/>
|
||||
<text text-anchor="middle" dy="4" fill="#000" font-size="10" font-weight="700">KY</text>
|
||||
<text y="-22" text-anchor="middle" fill="#5af9ff" font-size="10" font-weight="600">김영수</text>
|
||||
</g>
|
||||
|
||||
<!-- 범례 -->
|
||||
<g transform="translate(22 568)">
|
||||
<circle r="5" fill="#5af9ff" cx="6" cy="0"/>
|
||||
<text x="18" y="4" fill="#cfd3d8" font-size="10">담당자</text>
|
||||
<circle r="5" fill="#ff4f9a" cx="80" cy="0"/>
|
||||
<text x="92" y="4" fill="#cfd3d8" font-size="10">알람 위치</text>
|
||||
<line x1="160" y1="0" x2="190" y2="0" stroke="#ff8a3a" stroke-width="2" stroke-dasharray="4 3"/>
|
||||
<text x="200" y="4" fill="#cfd3d8" font-size="10">이동 경로</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<footer class="map-panel-foot">
|
||||
<span class="map-status-dot"></span>
|
||||
<span id="map-officer-status">김영수 책임자 — 관제실 대기</span>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<!-- 핸드폰 알림 (우상단 fixed) -->
|
||||
<div class="phone-notify" id="phone-notify">
|
||||
<div class="phone-frame">
|
||||
<div class="phone-notch"></div>
|
||||
<div class="phone-screen">
|
||||
<div class="phone-time" id="phone-time">--:--</div>
|
||||
<div class="phone-card">
|
||||
<div class="phone-card-head">
|
||||
<span class="phone-app-icon">🚨</span>
|
||||
<span class="phone-app-name">INVYONE EHS</span>
|
||||
<span class="phone-time-dim">지금</span>
|
||||
</div>
|
||||
<div class="phone-card-title">긴급 알람 발생</div>
|
||||
<div class="phone-card-msg" id="phone-msg">P-IN 토출압 HH — 즉시 점검 요망</div>
|
||||
<div class="phone-card-actions">
|
||||
<button class="phone-btn phone-btn-dismiss">나중에</button>
|
||||
<button class="phone-btn phone-btn-confirm" id="phone-confirm">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모달 (CCTV + 담당자) -->
|
||||
<div class="emergency-modal-backdrop" id="emergency-modal">
|
||||
<div class="emergency-modal">
|
||||
<header class="modal-head">
|
||||
<div class="modal-head-left">
|
||||
<span class="modal-severity">CRITICAL</span>
|
||||
<span class="modal-code" id="modal-alarm-code">P-IN-HH</span>
|
||||
</div>
|
||||
<div class="modal-head-title" id="modal-alarm-title">BW-A1 펌프 과압 / 누설 의심</div>
|
||||
<button class="modal-close" id="modal-close" title="닫기">×</button>
|
||||
</header>
|
||||
<!-- 어디서 발생했는지 명확히 표시 -->
|
||||
<div class="modal-location">
|
||||
<span class="loc-icon">📍</span>
|
||||
<span class="loc-text">위치: <b id="modal-alarm-location">펌프룸 A · BW-A1 (원수 취수펌프 #1)</b></span>
|
||||
<span class="loc-pointer">↙ P&ID에서 ⚠ 깜빡이는 설비 확인</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- 좌: CCTV (placeholder, 실제 영상 자산 도착하면 교체) -->
|
||||
<section class="modal-cctv">
|
||||
<div class="cctv-bar">
|
||||
<span class="cctv-rec">● REC</span>
|
||||
<span class="cctv-cam" id="modal-cctv-cam">CAM-01 / PUMP ROOM A / BW-A1</span>
|
||||
<span class="cctv-time" id="modal-cctv-time">--:--:--</span>
|
||||
</div>
|
||||
<div class="cctv-screen">
|
||||
<div class="cctv-noise"></div>
|
||||
<div class="cctv-scanline"></div>
|
||||
<div class="cctv-placeholder">
|
||||
<video class="cctv-video cctv-video-going" src="video/cctv.mp4" muted playsinline preload="auto"></video>
|
||||
<video class="cctv-video cctv-video-arrived" src="video/cctv-arrived.mp4" muted playsinline preload="auto"></video>
|
||||
<div class="cctv-target-box">
|
||||
<span class="cctv-target-label">TARGET: BW-A1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cctv-meta">
|
||||
<span>해상도: 1920×1080</span>
|
||||
<span>FPS: 30</span>
|
||||
<span class="cctv-live">● LIVE</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 우: 담당자 카드 -->
|
||||
<section class="modal-officer">
|
||||
<div class="officer-head">담당자 정보</div>
|
||||
<div class="officer-card">
|
||||
<div class="officer-avatar" id="modal-officer-avatar"><img src="img/officer.png" alt="김영수"/></div>
|
||||
<div class="officer-info">
|
||||
<div class="officer-name" id="modal-officer-name">김영수</div>
|
||||
<div class="officer-title" id="modal-officer-title">안전관리책임자</div>
|
||||
<div class="officer-role" id="modal-officer-role">EHS 1차 대응자</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="officer-contact">
|
||||
<div class="officer-row"><span>휴대폰</span><b id="modal-officer-phone">010-7842-3019</b></div>
|
||||
<div class="officer-row"><span>현재 위치</span><b id="modal-officer-loc">관제실</b></div>
|
||||
<div class="officer-row"><span>응답 상태</span><b class="officer-status" id="modal-officer-status">📡 알림 발송 중...</b></div>
|
||||
</div>
|
||||
<button class="officer-action" id="modal-dispatch">
|
||||
<span class="dispatch-icon">📞</span>
|
||||
<span>즉시 출동 요청</span>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
<div class="modal-alarm-message">
|
||||
<div class="alarm-msg-label">▲ 알람 상세</div>
|
||||
<div class="alarm-msg-text" id="modal-alarm-message">BW-A1 토출 압력이 정상 운전 범위(4.0~5.0 bar)를 초과했습니다. 현장 점검이 즉시 필요합니다.</div>
|
||||
</div>
|
||||
<footer class="modal-foot">
|
||||
<button class="modal-btn modal-btn-skip" id="modal-skip">⏭ 스킵</button>
|
||||
<button class="modal-btn modal-btn-ack" id="modal-ack" disabled>✓ 확인 (ACK) — 담당자 도착 후 활성화</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="eventlog-wrap">
|
||||
<h2 style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span>EVENT LOG</span>
|
||||
<span style="display:flex;gap:6px">
|
||||
<button id="btn-clear-log" style="padding:2px 8px;font-size:10px;background:#030814;color:#cfd3d8;border:1px solid #2a3f5a;cursor:pointer">CLEAR</button>
|
||||
<button id="btn-deselect" style="padding:2px 8px;font-size:10px;background:#030814;color:#cfd3d8;border:1px solid #2a3f5a;cursor:pointer">선택해제</button>
|
||||
</span>
|
||||
</h2>
|
||||
<div id="event-log"></div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// SCADA 데모 → 작업자 폰 push 의 target user_id.
|
||||
// 부모 라우트(/scada) 가 ?worker=<id> 를 iframe src 로 전달한다.
|
||||
// 미지정이면 fetch 호출 자체를 스킵 (로컬 시연 모드).
|
||||
window.SCADA_ALARM_TARGET = new URLSearchParams(location.search).get('worker') || '';
|
||||
</script>
|
||||
<script src="js/components.js"></script>
|
||||
<script src="js/topology.js"></script>
|
||||
<script src="js/engine.js"></script>
|
||||
<script src="js/scenario.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,566 @@
|
||||
// INVYONE Stage-2 — SVG Component Templates
|
||||
// 각 함수는 SVG <g> 프래그먼트 문자열을 반환. 메인 씬 SVG에 innerHTML로 박는다.
|
||||
// 내부 ID 컨벤션: ${type}-${id}-${role} (예: tank-raw-liquid, pump-uf-impeller)
|
||||
// data-comp-id / data-comp-type 으로 클릭 핸들러가 식별
|
||||
|
||||
(function (global) {
|
||||
const COMP = {};
|
||||
|
||||
// ===== TANK (vertical rectangular liquid tank with side scale ticks + optional nozzles) =====
|
||||
COMP.tank = function (p) {
|
||||
const { id, x, y, w = 120, h = 200, label = 'TANK', color = '#5af9ff', nozzles = [] } = p;
|
||||
const ticks = [0, 25, 50, 75, 100].map(pct => {
|
||||
const ty = (h - 3) - (h - 6) * pct / 100;
|
||||
const isMajor = pct % 50 === 0;
|
||||
return `<line x1="${-(isMajor ? 5 : 3)}" y1="${ty}" x2="0" y2="${ty}" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>` +
|
||||
`<text x="-7" y="${ty + 2}" font-size="6" fill="#cfd3d8" text-anchor="end">${pct}</text>`;
|
||||
}).join('');
|
||||
const minorTicks = [10, 20, 30, 40, 60, 70, 80, 90].map(pct => {
|
||||
const ty = (h - 3) - (h - 6) * pct / 100;
|
||||
return `<line x1="-2" y1="${ty}" x2="0" y2="${ty}" stroke="#888" stroke-width="0.5" vector-effect="non-scaling-stroke"/>`;
|
||||
}).join('');
|
||||
let nozzleSVG = '';
|
||||
nozzles.forEach(n => {
|
||||
const len = n.len || 9;
|
||||
const pos = n.pos != null ? n.pos : 0.5;
|
||||
if (n.side === 'left') {
|
||||
const ny = (h - 6) * pos + 3;
|
||||
nozzleSVG += `<rect x="${-len}" y="${ny - 4}" width="${len}" height="8" fill="url(#metal-h)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>`;
|
||||
nozzleSVG += `<rect x="${-len - 2}" y="${ny - 6}" width="2" height="12" fill="url(#metal-v)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>`;
|
||||
} else if (n.side === 'right') {
|
||||
const ny = (h - 6) * pos + 3;
|
||||
nozzleSVG += `<rect x="${w}" y="${ny - 4}" width="${len}" height="8" fill="url(#metal-h)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>`;
|
||||
nozzleSVG += `<rect x="${w + len}" y="${ny - 6}" width="2" height="12" fill="url(#metal-v)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>`;
|
||||
} else if (n.side === 'top') {
|
||||
const nx = (w - 6) * pos + 3;
|
||||
nozzleSVG += `<rect x="${nx - 4}" y="${-len}" width="8" height="${len}" fill="url(#metal-v)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>`;
|
||||
nozzleSVG += `<rect x="${nx - 6}" y="${-len - 2}" width="12" height="2" fill="url(#metal-h)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>`;
|
||||
} else if (n.side === 'bot') {
|
||||
const nx = (w - 6) * pos + 3;
|
||||
nozzleSVG += `<rect x="${nx - 4}" y="${h}" width="8" height="${len}" fill="url(#metal-v)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>`;
|
||||
nozzleSVG += `<rect x="${nx - 6}" y="${h + len}" width="12" height="2" fill="url(#metal-h)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>`;
|
||||
}
|
||||
});
|
||||
return `
|
||||
<g class="comp comp-tank" data-comp-id="${id}" data-comp-type="tank" transform="translate(${x} ${y})">
|
||||
<text x="${w / 2}" y="-8" class="comp-label" font-size="11" text-anchor="middle">${label}</text>
|
||||
${minorTicks}
|
||||
${ticks}
|
||||
${nozzleSVG}
|
||||
<rect class="tank-shell" x="0" y="0" width="${w}" height="${h}" fill="url(#metal-tank)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect x="3" y="3" width="${w - 6}" height="${h - 6}" fill="#071326" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect x="4" y="4" width="${w - 8}" height="${h - 8}" fill="url(#glass-grad)" pointer-events="none"/>
|
||||
<clipPath id="tank-${id}-clip"><rect x="3" y="3" width="${w - 6}" height="${h - 6}"/></clipPath>
|
||||
<rect id="tank-${id}-liquid" x="3" y="${h - 3}" width="${w - 6}" height="0" fill="${color}" clip-path="url(#tank-${id}-clip)"/>
|
||||
<ellipse id="tank-${id}-surface" cx="${w / 2}" cy="${h - 3}" rx="${(w - 6) / 2 - 2}" ry="2.2" fill="${color}" opacity="0" clip-path="url(#tank-${id}-clip)"/>
|
||||
<rect id="tank-${id}-level-bg" x="${w / 2 - 26}" y="${h / 2 - 10}" width="52" height="20" rx="3" fill="#000" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<text id="tank-${id}-text" x="${w / 2}" y="${h / 2 + 5}" class="tank-text" font-size="13" text-anchor="middle">0.0%</text>
|
||||
</g>`;
|
||||
};
|
||||
|
||||
// ===== SMALL DOSING TANK =====
|
||||
COMP.dosingTank = function (p) {
|
||||
const { id, x, y, label = 'CHEM', color = '#7cff3a' } = p;
|
||||
const w = 50, h = 70;
|
||||
return `
|
||||
<g class="comp comp-tank comp-tank-small" data-comp-id="${id}" data-comp-type="tank" transform="translate(${x} ${y})">
|
||||
<text x="${w / 2}" y="-4" class="comp-label" font-size="8" text-anchor="middle">${label}</text>
|
||||
<rect class="tank-shell" x="0" y="0" width="${w}" height="${h}" fill="url(#metal-tank)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect x="2" y="2" width="${w - 4}" height="${h - 4}" fill="#071326" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<clipPath id="tank-${id}-clip"><rect x="2" y="2" width="${w - 4}" height="${h - 4}"/></clipPath>
|
||||
<rect id="tank-${id}-liquid" x="2" y="${h - 2}" width="${w - 4}" height="0" fill="${color}" clip-path="url(#tank-${id}-clip)"/>
|
||||
<ellipse id="tank-${id}-surface" cx="${w / 2}" cy="${h - 2}" rx="${(w - 4) / 2 - 1}" ry="1.5" fill="${color}" opacity="0" clip-path="url(#tank-${id}-clip)"/>
|
||||
<text id="tank-${id}-text" x="${w / 2}" y="${h / 2 + 3}" class="tank-text" font-size="9" text-anchor="middle">0%</text>
|
||||
</g>`;
|
||||
};
|
||||
|
||||
// ===== AC / SAND TANK (conical top + cylindrical body + solid skirt base) =====
|
||||
COMP.acTank = function (p) {
|
||||
const { id, x, y, w = 96, h = 150, label = 'AC TANK', color = '#5af9ff' } = p;
|
||||
const coneH = 22;
|
||||
const bodyY = coneH;
|
||||
const skirtH = 8;
|
||||
const bodyH = h - coneH - skirtH;
|
||||
return `
|
||||
<g class="comp comp-tank comp-tank-ac" data-comp-id="${id}" data-comp-type="tank" transform="translate(${x} ${y})">
|
||||
<text x="${w / 2}" y="-8" class="comp-label" font-size="10" text-anchor="middle">${label}</text>
|
||||
<path d="M${w / 2 - 10} 0 L${w / 2 + 10} 0 L${w} ${coneH} L0 ${coneH} Z" fill="url(#metal-tank)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect x="2" y="${bodyY}" width="${w - 4}" height="${bodyH}" fill="url(#metal-tank)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect x="6" y="${bodyY + 4}" width="${w - 12}" height="${bodyH - 8}" fill="#071326" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<clipPath id="tank-${id}-clip"><rect x="6" y="${bodyY + 4}" width="${w - 12}" height="${bodyH - 8}"/></clipPath>
|
||||
<rect id="tank-${id}-liquid" x="6" y="${bodyY + bodyH - 4}" width="${w - 12}" height="0" fill="${color}" clip-path="url(#tank-${id}-clip)"/>
|
||||
<ellipse id="tank-${id}-surface" cx="${w / 2}" cy="${bodyY + bodyH - 4}" rx="${(w - 12) / 2 - 1}" ry="1.6" fill="${color}" opacity="0" clip-path="url(#tank-${id}-clip)"/>
|
||||
<text id="tank-${id}-text" x="${w / 2}" y="${bodyY + bodyH / 2 + 5}" class="tank-text" font-size="11" text-anchor="middle">0%</text>
|
||||
<rect x="${w / 2 - (w / 2 - 6)}" y="${bodyY + bodyH}" width="${w - 12}" height="${skirtH}" fill="url(#metal-tank)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect x="${w / 2 - 5}" y="${h}" width="10" height="6" fill="url(#metal-v)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
</g>`;
|
||||
};
|
||||
|
||||
// ===== CENTRIFUGAL PUMP (with motor cooling fins + flow direction arrow) =====
|
||||
COMP.pump = function (p) {
|
||||
const { id, x, y, label = '', accent = '#7cff3a' } = p;
|
||||
return `
|
||||
<g class="comp comp-pump" data-comp-id="${id}" data-comp-type="pump" transform="translate(${x} ${y})">
|
||||
${label ? `<text x="30" y="76" class="comp-label" font-size="9" text-anchor="middle">${label}</text>` : ''}
|
||||
<rect id="pump-${id}-inlet" x="1" y="25" width="16" height="10" fill="url(#metal-h)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect id="pump-${id}-outlet" x="43" y="25" width="16" height="10" fill="url(#metal-h)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect class="pump-motor" x="36" y="14" width="14" height="14" rx="2" fill="url(#metal-h)" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<line x1="39" y1="16" x2="39" y2="26" stroke="#666" stroke-width="0.4" vector-effect="non-scaling-stroke"/>
|
||||
<line x1="42" y1="16" x2="42" y2="26" stroke="#666" stroke-width="0.4" vector-effect="non-scaling-stroke"/>
|
||||
<line x1="45" y1="16" x2="45" y2="26" stroke="#666" stroke-width="0.4" vector-effect="non-scaling-stroke"/>
|
||||
<line x1="48" y1="16" x2="48" y2="26" stroke="#666" stroke-width="0.4" vector-effect="non-scaling-stroke"/>
|
||||
<circle class="pump-housing" cx="30" cy="30" r="17" fill="#071326" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<circle cx="30" cy="30" r="12" fill="none" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<g id="pump-${id}-impeller" class="pump-impeller">
|
||||
<path d="M30 30L30 18A12 12 0 0 1 40 24Z" fill="#5af9ff" opacity="0.88"/>
|
||||
<path d="M30 30L42 30A12 12 0 0 1 34 41Z" fill="#5af9ff" opacity="0.65"/>
|
||||
<path d="M30 30L20 37A12 12 0 0 1 19 25Z" fill="#5af9ff" opacity="0.5"/>
|
||||
<circle cx="30" cy="30" r="3" fill="#fff"/>
|
||||
</g>
|
||||
<path class="pump-arrow" d="M22 49 L34 49 L34 47 L38 50 L34 53 L34 51 L22 51 Z" fill="#666"/>
|
||||
<circle id="pump-${id}-led" class="pump-led" cx="47" cy="47" r="4" fill="#2a3f5a" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
</g>`;
|
||||
};
|
||||
|
||||
// ===== VERTICAL PUMP (top-inlet, right-outlet — for tank-foot pumps and chem dosing) =====
|
||||
COMP.pumpV = function (p) {
|
||||
const { id, x, y, label = '' } = p;
|
||||
return `
|
||||
<g class="comp comp-pump comp-pump-v" data-comp-id="${id}" data-comp-type="pump" transform="translate(${x} ${y})">
|
||||
${label ? `<text x="30" y="86" class="comp-label" font-size="9" text-anchor="middle">${label}</text>` : ''}
|
||||
<rect id="pump-${id}-inlet" x="25" y="0" width="10" height="14" fill="url(#metal-v)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect class="pump-motor" x="15" y="12" width="30" height="14" rx="2" fill="url(#metal-h)" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<line x1="20" y1="14" x2="20" y2="24" stroke="#666" stroke-width="0.4" vector-effect="non-scaling-stroke"/>
|
||||
<line x1="25" y1="14" x2="25" y2="24" stroke="#666" stroke-width="0.4" vector-effect="non-scaling-stroke"/>
|
||||
<line x1="30" y1="14" x2="30" y2="24" stroke="#666" stroke-width="0.4" vector-effect="non-scaling-stroke"/>
|
||||
<line x1="35" y1="14" x2="35" y2="24" stroke="#666" stroke-width="0.4" vector-effect="non-scaling-stroke"/>
|
||||
<line x1="40" y1="14" x2="40" y2="24" stroke="#666" stroke-width="0.4" vector-effect="non-scaling-stroke"/>
|
||||
<circle class="pump-housing" cx="30" cy="46" r="18" fill="#071326" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<circle cx="30" cy="46" r="13" fill="none" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<g id="pump-${id}-impeller" class="pump-impeller">
|
||||
<path d="M30 46L30 33A13 13 0 0 1 41 40Z" fill="#5af9ff" opacity="0.88"/>
|
||||
<path d="M30 46L43 46A13 13 0 0 1 35 58Z" fill="#5af9ff" opacity="0.65"/>
|
||||
<path d="M30 46L19 53A13 13 0 0 1 18 41Z" fill="#5af9ff" opacity="0.5"/>
|
||||
<circle cx="30" cy="46" r="3" fill="#fff"/>
|
||||
</g>
|
||||
<rect id="pump-${id}-outlet" x="48" y="42" width="12" height="10" fill="url(#metal-h)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<circle id="pump-${id}-led" class="pump-led" cx="50" cy="62" r="3" fill="#2a3f5a" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
</g>`;
|
||||
};
|
||||
|
||||
// ===== AIR BLOWER / COMPRESSOR =====
|
||||
// Visual: cylindrical body, fan housing on left with 3 curved blades, motor on right
|
||||
COMP.airBlower = function (p) {
|
||||
const { id, x, y, label = 'AIR BLOWER' } = p;
|
||||
return `
|
||||
<g class="comp comp-pump comp-blower" data-comp-id="${id}" data-comp-type="pump" transform="translate(${x} ${y})">
|
||||
<text x="40" y="-4" class="comp-label" font-size="9" text-anchor="middle">${label}</text>
|
||||
<rect x="0" y="0" width="80" height="50" rx="6" fill="url(#metal-h)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<circle class="pump-housing" cx="22" cy="25" r="15" fill="#071326" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<circle cx="22" cy="25" r="11" fill="none" stroke="#aaa" stroke-width="0.6" vector-effect="non-scaling-stroke"/>
|
||||
<g id="pump-${id}-impeller" class="pump-impeller blower-fan">
|
||||
<path d="M22 25 Q22 14 31 13 Q26 19 22 25 Z" fill="#5af9ff" opacity="0.88"/>
|
||||
<path d="M22 25 Q33 25 34 34 Q28 29 22 25 Z" fill="#5af9ff" opacity="0.65"/>
|
||||
<path d="M22 25 Q22 36 13 37 Q18 31 22 25 Z" fill="#5af9ff" opacity="0.5"/>
|
||||
<path d="M22 25 Q11 25 10 16 Q16 21 22 25 Z" fill="#5af9ff" opacity="0.75"/>
|
||||
<circle cx="22" cy="25" r="2.5" fill="#fff"/>
|
||||
</g>
|
||||
<rect class="pump-motor" x="44" y="14" width="32" height="22" rx="3" fill="url(#metal-h)" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<line x1="37" y1="25" x2="44" y2="25" stroke="#666" stroke-width="2" vector-effect="non-scaling-stroke"/>
|
||||
<rect id="pump-${id}-outlet" x="76" y="20" width="6" height="10" fill="url(#metal-h)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<circle id="pump-${id}-led" class="pump-led" cx="60" cy="25" r="2.5" fill="#2a3f5a" stroke="#fff" stroke-width="0.8" vector-effect="non-scaling-stroke"/>
|
||||
</g>`;
|
||||
};
|
||||
|
||||
// ===== VALVE (gate / ball) — sits on top of the underlying pipe; no own pipe rect =====
|
||||
// orientation: 'h' (default, horizontal pipe) or 'v' (rotated 90° for vertical pipe)
|
||||
COMP.valve = function (p) {
|
||||
const { id, x, y, label = '', orientation = 'h' } = p;
|
||||
const rot = orientation === 'v' ? ' transform="rotate(90 25 26)"' : '';
|
||||
const labelY = orientation === 'v' ? -16 : -2;
|
||||
const labelX = orientation === 'v' ? 38 : 25;
|
||||
const labelAnchor = orientation === 'v' ? 'start' : 'middle';
|
||||
return `
|
||||
<g class="comp comp-valve" data-comp-id="${id}" data-comp-type="valve" data-orient="${orientation}" transform="translate(${x} ${y})">
|
||||
${label ? `<text x="${labelX}" y="${labelY}" class="comp-label" font-size="8" text-anchor="${labelAnchor}">${label}</text>` : ''}
|
||||
<g class="valve-rot"${rot}>
|
||||
<circle id="valve-${id}-body" class="valve-body" cx="25" cy="26" r="11" fill="#071326" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect x="14" y="23" width="22" height="6" rx="2" fill="#2a3f5a" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<line id="valve-${id}-flow-l" class="valve-flow flow" x1="0" y1="26" x2="14" y2="26" stroke="#5af9ff" stroke-width="2" stroke-dasharray="4 4" stroke-linecap="round" vector-effect="non-scaling-stroke" fill="none"/>
|
||||
<line id="valve-${id}-flow-r" class="valve-flow flow" x1="36" y1="26" x2="50" y2="26" stroke="#5af9ff" stroke-width="2" stroke-dasharray="4 4" stroke-linecap="round" vector-effect="non-scaling-stroke" fill="none"/>
|
||||
<line x1="25" y1="15" x2="25" y2="8" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<g id="valve-${id}-handle" class="valve-handle">
|
||||
<rect x="14" y="5" width="22" height="6" rx="2" fill="#7cff3a" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>`;
|
||||
};
|
||||
|
||||
// ===== ORTHOGONAL PIPE (rect-based duct segments + flow path overlay) =====
|
||||
// Supports `gaps`: array of { x, y } where this pipe should leave a 16px crossover gap
|
||||
// (used to render P&ID-style "duck under" at orthogonal crossings)
|
||||
COMP.pipe = function (p) {
|
||||
if (Array.isArray(p.points) && p.points.length >= 2) {
|
||||
const d = p.points.map((pt, i) => `${i === 0 ? 'M' : 'L'}${pt[0]} ${pt[1]}`).join(' ');
|
||||
const gaps = p.gaps || [];
|
||||
const GH = 9; // half-gap in px
|
||||
const segs = [];
|
||||
const pushH = (xMin, xMax, y) => {
|
||||
const w = xMax - xMin;
|
||||
if (w <= 0) return;
|
||||
segs.push(`<rect class="pipe-outer" x="${xMin}" y="${y - 6}" width="${w}" height="12" fill="url(#metal-h)" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>`);
|
||||
segs.push(`<rect class="pipe-inner" x="${xMin + 2}" y="${y - 3}" width="${Math.max(0, w - 4)}" height="6" fill="#2a3f5a" vector-effect="non-scaling-stroke"/>`);
|
||||
};
|
||||
const pushV = (yMin, yMax, x) => {
|
||||
const h = yMax - yMin;
|
||||
if (h <= 0) return;
|
||||
segs.push(`<rect class="pipe-outer" x="${x - 6}" y="${yMin}" width="12" height="${h}" fill="url(#metal-v)" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>`);
|
||||
segs.push(`<rect class="pipe-inner" x="${x - 3}" y="${yMin + 2}" width="6" height="${Math.max(0, h - 4)}" fill="#2a3f5a" vector-effect="non-scaling-stroke"/>`);
|
||||
};
|
||||
const jumps = [];
|
||||
for (let i = 1; i < p.points.length; i++) {
|
||||
const [x1, y1] = p.points[i - 1];
|
||||
const [x2, y2] = p.points[i];
|
||||
if (y1 === y2) {
|
||||
const xMin = Math.min(x1, x2) - 6, xMax = Math.max(x1, x2) + 6;
|
||||
const segGaps = gaps.filter(g => g.y === y1 && g.x > Math.min(x1, x2) && g.x < Math.max(x1, x2)).sort((a, b) => a.x - b.x);
|
||||
let cur = xMin;
|
||||
segGaps.forEach(g => {
|
||||
pushH(cur, g.x - GH, y1);
|
||||
// a small jumper arc above the gap
|
||||
jumps.push(`<path d="M${g.x - GH} ${y1 - 6} Q${g.x} ${y1 - 14} ${g.x + GH} ${y1 - 6} L${g.x + GH} ${y1 + 6} Q${g.x} ${y1 - 2} ${g.x - GH} ${y1 + 6} Z" fill="url(#metal-h)" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>`);
|
||||
cur = g.x + GH;
|
||||
});
|
||||
pushH(cur, xMax, y1);
|
||||
} else if (x1 === x2) {
|
||||
const yMin = Math.min(y1, y2) - 6, yMax = Math.max(y1, y2) + 6;
|
||||
const segGaps = gaps.filter(g => g.x === x1 && g.y > Math.min(y1, y2) && g.y < Math.max(y1, y2)).sort((a, b) => a.y - b.y);
|
||||
let cur = yMin;
|
||||
segGaps.forEach(g => {
|
||||
pushV(cur, g.y - GH, x1);
|
||||
// small jumper arc to the right of the gap (vertical-going-over visualization)
|
||||
jumps.push(`<path d="M${x1 - 6} ${g.y - GH} Q${x1 + 8} ${g.y} ${x1 - 6} ${g.y + GH} L${x1 + 6} ${g.y + GH} Q${x1 - 6} ${g.y} ${x1 + 6} ${g.y - GH} Z" fill="url(#metal-v)" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>`);
|
||||
cur = g.y + GH;
|
||||
});
|
||||
pushV(cur, yMax, x1);
|
||||
}
|
||||
}
|
||||
// Build flow path with arc detours at gaps so the dash flow visually goes over
|
||||
const flowSegs = [];
|
||||
flowSegs.push(`M${p.points[0][0]} ${p.points[0][1]}`);
|
||||
for (let i = 1; i < p.points.length; i++) {
|
||||
const [x1, y1] = p.points[i - 1];
|
||||
const [x2, y2] = p.points[i];
|
||||
if (y1 === y2) {
|
||||
const segGaps = gaps.filter(g => g.y === y1 && g.x > Math.min(x1, x2) && g.x < Math.max(x1, x2)).sort((a, b) => x2 > x1 ? a.x - b.x : b.x - a.x);
|
||||
segGaps.forEach(g => {
|
||||
const dir = x2 > x1 ? 1 : -1;
|
||||
flowSegs.push(`L${g.x - GH * dir} ${y1}`);
|
||||
flowSegs.push(`A${GH} ${GH} 0 0 1 ${g.x + GH * dir} ${y1}`);
|
||||
});
|
||||
flowSegs.push(`L${x2} ${y2}`);
|
||||
} else if (x1 === x2) {
|
||||
const segGaps = gaps.filter(g => g.x === x1 && g.y > Math.min(y1, y2) && g.y < Math.max(y1, y2)).sort((a, b) => y2 > y1 ? a.y - b.y : b.y - a.y);
|
||||
segGaps.forEach(g => {
|
||||
const dir = y2 > y1 ? 1 : -1;
|
||||
flowSegs.push(`L${x1} ${g.y - GH * dir}`);
|
||||
flowSegs.push(`A${GH} ${GH} 0 0 0 ${x1} ${g.y + GH * dir}`);
|
||||
});
|
||||
flowSegs.push(`L${x2} ${y2}`);
|
||||
}
|
||||
}
|
||||
const flowD = flowSegs.join(' ');
|
||||
return `
|
||||
<g class="comp comp-pipe" data-comp-id="${p.id}" data-comp-type="pipe">
|
||||
${segs.join('\n ')}
|
||||
${jumps.join('\n ')}
|
||||
<path id="pipe-${p.id}-fluid" class="pipe-fluid" d="${flowD}" fill="none" stroke="#1ab2c9" stroke-width="6" stroke-linejoin="round" stroke-linecap="butt" stroke-dasharray="9999" stroke-dashoffset="9999" vector-effect="non-scaling-stroke"/>
|
||||
<path id="pipe-${p.id}-flow" class="pipe-flow flow" d="${flowD}" fill="none" stroke="#5af9ff" stroke-width="2" stroke-dasharray="4 4" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"/>
|
||||
</g>`;
|
||||
}
|
||||
// fallback for x1/y1/x2/y2 only
|
||||
const { id, x1, y1, x2, y2 } = p;
|
||||
const horizontal = y1 === y2;
|
||||
if (horizontal) {
|
||||
const x = Math.min(x1, x2), w = Math.abs(x2 - x1);
|
||||
return `
|
||||
<g class="comp comp-pipe" data-comp-id="${id}" data-comp-type="pipe" transform="translate(${x} ${y1 - 6})">
|
||||
<rect class="pipe-outer" x="0" y="0" width="${w}" height="12" fill="url(#metal-h)" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect class="pipe-inner" x="2" y="3" width="${w - 4}" height="6" fill="#2a3f5a" vector-effect="non-scaling-stroke"/>
|
||||
<line id="pipe-${id}-flow" class="pipe-flow flow" x1="4" y1="6" x2="${w - 4}" y2="6" stroke="#5af9ff" stroke-width="2" stroke-dasharray="4 4" stroke-linecap="round" vector-effect="non-scaling-stroke" fill="none"/>
|
||||
</g>`;
|
||||
} else {
|
||||
const y = Math.min(y1, y2), h = Math.abs(y2 - y1);
|
||||
return `
|
||||
<g class="comp comp-pipe" data-comp-id="${id}" data-comp-type="pipe" transform="translate(${x1 - 6} ${y})">
|
||||
<rect class="pipe-outer" x="0" y="0" width="12" height="${h}" fill="url(#metal-v)" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect class="pipe-inner" x="3" y="2" width="6" height="${h - 4}" fill="#2a3f5a" vector-effect="non-scaling-stroke"/>
|
||||
<line id="pipe-${id}-flow" class="pipe-flow flow" x1="6" y1="4" x2="6" y2="${h - 4}" stroke="#5af9ff" stroke-width="2" stroke-dasharray="4 4" stroke-linecap="round" vector-effect="non-scaling-stroke" fill="none"/>
|
||||
</g>`;
|
||||
}
|
||||
};
|
||||
|
||||
// ===== PRESSURE GAUGE (analog dial) =====
|
||||
COMP.pressureGauge = function (p) {
|
||||
const { id, x, y, label = 'P', max = 8, unit = 'bar' } = p;
|
||||
return `
|
||||
<g class="comp comp-gauge" data-comp-id="${id}" data-comp-type="gauge" data-max="${max}" data-unit="${unit}" transform="translate(${x} ${y})">
|
||||
${label ? `<text x="40" y="-3" class="comp-label" font-size="8" text-anchor="middle">${label}</text>` : ''}
|
||||
<circle cx="40" cy="40" r="25" fill="url(#metal-h)" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<circle cx="40" cy="40" r="22" fill="#0b1528" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<circle id="gauge-${id}-bg" cx="40" cy="40" r="18" fill="#081326" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<path d="M24.4 48 A17 17 0 0 1 30.7 25.8" fill="none" stroke="#7cff3a" stroke-width="2" vector-effect="non-scaling-stroke"/>
|
||||
<path d="M30.7 25.8 A17 17 0 0 1 49.3 25.8" fill="none" stroke="#ff8a3a" stroke-width="2" vector-effect="non-scaling-stroke"/>
|
||||
<path d="M49.3 25.8 A17 17 0 0 1 55.6 48" fill="none" stroke="#ff4f9a" stroke-width="2" vector-effect="non-scaling-stroke"/>
|
||||
<g stroke="#fff" fill="none" vector-effect="non-scaling-stroke">
|
||||
<path d="M40 17 L40 21" transform="rotate(-120 40 40)"/>
|
||||
<path d="M40 17 L40 21" transform="rotate(-60 40 40)"/>
|
||||
<path d="M40 17 L40 21" transform="rotate(0 40 40)"/>
|
||||
<path d="M40 17 L40 21" transform="rotate(60 40 40)"/>
|
||||
<path d="M40 17 L40 21" transform="rotate(120 40 40)"/>
|
||||
</g>
|
||||
<text x="40" y="33" class="gauge-title" font-size="5" text-anchor="middle">PRESS</text>
|
||||
<text x="40" y="55" class="gauge-unit" font-size="5" text-anchor="middle">${unit}</text>
|
||||
<g id="gauge-${id}-needle" class="gauge-needle">
|
||||
<path d="M40 40 L40 24" fill="none" stroke="#ff8a3a" stroke-width="2" stroke-linecap="round" vector-effect="non-scaling-stroke"/>
|
||||
<path d="M38.5 41.5 L40 22.5 L41.5 41.5 Z" fill="#ff8a3a" opacity="0.18"/>
|
||||
</g>
|
||||
<circle cx="40" cy="40" r="4" fill="#111a2b" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<circle cx="40" cy="40" r="2" fill="url(#metal-h)" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect x="25" y="59" width="30" height="10" rx="2" fill="#000" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<text id="gauge-${id}-text" x="40" y="66.5" class="gauge-readout" font-size="8" text-anchor="middle">0.0</text>
|
||||
</g>`;
|
||||
};
|
||||
|
||||
// ===== SENSOR READOUT =====
|
||||
COMP.sensor = function (p) {
|
||||
const { id, x, y, label = 'SENSOR', value = 0, unit = '' } = p;
|
||||
const w = 110, h = 36;
|
||||
return `
|
||||
<g class="comp comp-sensor" data-comp-id="${id}" data-comp-type="sensor" data-unit="${unit}" transform="translate(${x} ${y})">
|
||||
<rect x="0" y="0" width="${w}" height="${h}" rx="3" fill="#071326" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<text id="sensor-${id}-label" x="6" y="13" class="sensor-label" font-size="8">${label}</text>
|
||||
<text id="sensor-${id}-value" x="${w - 6}" y="26" class="sensor-value" font-size="14" text-anchor="end">${value}</text>
|
||||
<text id="sensor-${id}-unit" x="${w - 6}" y="33" class="sensor-unit" font-size="7" text-anchor="end">${unit}</text>
|
||||
</g>`;
|
||||
};
|
||||
|
||||
// ===== STATUS LED =====
|
||||
COMP.led = function (p) {
|
||||
const { id, x, y, label = '' } = p;
|
||||
return `
|
||||
<g class="comp comp-led" data-comp-id="${id}" data-comp-type="led" transform="translate(${x} ${y})">
|
||||
${label ? `<text x="14" y="22" class="comp-label" font-size="7" text-anchor="middle">${label}</text>` : ''}
|
||||
<circle id="led-${id}-status" class="led-status" cx="7" cy="7" r="5" fill="#2a3f5a" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
</g>`;
|
||||
};
|
||||
|
||||
// ===== INLINE CARTRIDGE FILTER (housing + visible cartridge element + active fluid flow) =====
|
||||
COMP.filter = function (p) {
|
||||
const { id, x, y, label = 'FILTER' } = p;
|
||||
const w = 32, h = 82;
|
||||
let mesh = '';
|
||||
const elTop = 14, elBot = h - 14, elL = w / 2 - 6, elR = w / 2 + 6;
|
||||
const rows = 9;
|
||||
for (let i = 0; i < rows; i++) {
|
||||
const ly = elTop + (elBot - elTop) * i / rows;
|
||||
mesh += `<path d="M${elL} ${ly} L${w / 2} ${ly + 3} L${elR} ${ly}" stroke="#aaa" stroke-width="0.5" fill="none" opacity="0.55" vector-effect="non-scaling-stroke"/>`;
|
||||
}
|
||||
const midY = h / 2;
|
||||
return `
|
||||
<g class="comp comp-filter" data-comp-id="${id}" data-comp-type="filter" transform="translate(${x} ${y})">
|
||||
${label ? `<text x="${w / 2}" y="-6" class="comp-label" font-size="8" text-anchor="middle">${label}</text>` : ''}
|
||||
<!-- top end cap with bolt ring -->
|
||||
<ellipse cx="${w / 2}" cy="4" rx="${w / 2 - 1}" ry="4" fill="url(#metal-h)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<line x1="${w / 2 - 8}" y1="3" x2="${w / 2 - 8}" y2="6" stroke="#666" stroke-width="0.5" vector-effect="non-scaling-stroke"/>
|
||||
<line x1="${w / 2 + 8}" y1="3" x2="${w / 2 + 8}" y2="6" stroke="#666" stroke-width="0.5" vector-effect="non-scaling-stroke"/>
|
||||
<!-- transparent housing shell -->
|
||||
<rect x="1" y="4" width="${w - 2}" height="${h - 8}" fill="url(#metal-tank)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke" opacity="0.55"/>
|
||||
<rect class="filter-housing-bg" x="3" y="6" width="${w - 6}" height="${h - 12}" fill="#0b1528" stroke="#aaa" stroke-width="0.5" vector-effect="non-scaling-stroke"/>
|
||||
<!-- housing fluid fill (수조 안에 액체가 차오르는 시각) -->
|
||||
<rect class="filter-housing-fluid" x="3" y="6" width="${w - 6}" height="${h - 12}" fill="#5af9ff" opacity="0" vector-effect="non-scaling-stroke"/>
|
||||
<!-- inner cartridge element with pleated media -->
|
||||
<rect class="filter-element" x="${elL}" y="${elTop}" width="${elR - elL}" height="${elBot - elTop}" rx="2" fill="#1a2a45" stroke="#aaa" stroke-width="0.6" vector-effect="non-scaling-stroke"/>
|
||||
<rect class="filter-fluid" x="${elL + 0.5}" y="${elTop + 0.5}" width="${elR - elL - 1}" height="${elBot - elTop - 1}" rx="1.5" fill="#5af9ff" opacity="0"/>
|
||||
${mesh}
|
||||
<!-- core perforation -->
|
||||
<line class="filter-core-flow" x1="${w / 2}" y1="${elTop + 1}" x2="${w / 2}" y2="${elBot - 1}" stroke="#5af9ff" stroke-width="0.7" stroke-dasharray="2 2" opacity="0.5" vector-effect="non-scaling-stroke"/>
|
||||
<!-- support rods (housing structure) -->
|
||||
<line x1="${w / 2 - 4}" y1="14" x2="${w / 2 - 4}" y2="${h - 14}" stroke="#aaa" stroke-width="0.5" opacity="0.4" vector-effect="non-scaling-stroke"/>
|
||||
<line x1="${w / 2 + 4}" y1="14" x2="${w / 2 + 4}" y2="${h - 14}" stroke="#aaa" stroke-width="0.5" opacity="0.4" vector-effect="non-scaling-stroke"/>
|
||||
<!-- internal flow dashes (좌측 housing → 카트리지 → 우측 housing) -->
|
||||
<line class="filter-internal-flow flow" x1="3" y1="${midY}" x2="${elL}" y2="${midY}" stroke="#5af9ff" stroke-width="1.6" stroke-dasharray="3 3" stroke-linecap="round" vector-effect="non-scaling-stroke" fill="none"/>
|
||||
<line class="filter-internal-flow flow" x1="${elL}" y1="${midY}" x2="${elR}" y2="${midY}" stroke="#5af9ff" stroke-width="1.6" stroke-dasharray="3 3" stroke-linecap="round" vector-effect="non-scaling-stroke" fill="none"/>
|
||||
<line class="filter-internal-flow flow" x1="${elR}" y1="${midY}" x2="${w - 3}" y2="${midY}" stroke="#5af9ff" stroke-width="1.6" stroke-dasharray="3 3" stroke-linecap="round" vector-effect="non-scaling-stroke" fill="none"/>
|
||||
<!-- inlet/outlet flow arrows (외부, housing 양 끝) -->
|
||||
<path class="filter-arrow filter-arrow-in" d="M-3 ${midY - 4} L4 ${midY} L-3 ${midY + 4} Z" fill="#5af9ff" opacity="0.5"/>
|
||||
<path class="filter-arrow filter-arrow-out" d="M${w - 4} ${midY - 4} L${w + 3} ${midY} L${w - 4} ${midY + 4} Z" fill="#5af9ff" opacity="0.5"/>
|
||||
<!-- bottom end cap with bolt ring -->
|
||||
<ellipse cx="${w / 2}" cy="${h - 4}" rx="${w / 2 - 1}" ry="4" fill="url(#metal-h)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<line x1="${w / 2 - 8}" y1="${h - 6}" x2="${w / 2 - 8}" y2="${h - 3}" stroke="#666" stroke-width="0.5" vector-effect="non-scaling-stroke"/>
|
||||
<line x1="${w / 2 + 8}" y1="${h - 6}" x2="${w / 2 + 8}" y2="${h - 3}" stroke="#666" stroke-width="0.5" vector-effect="non-scaling-stroke"/>
|
||||
<!-- drain valve indicator -->
|
||||
<rect x="${w / 2 - 1.5}" y="${h - 1}" width="3" height="3" fill="url(#metal-v)" stroke="#aaa" stroke-width="0.4" vector-effect="non-scaling-stroke"/>
|
||||
</g>`;
|
||||
};
|
||||
|
||||
// ===== MEMBRANE BANK (UF=vertical bottles / RO=horizontal cylinders) =====
|
||||
COMP.membraneBank = function (p) {
|
||||
const { id, x, y, count = 5, label = 'MEMBRANE', accent = '#5af9ff', orientation = 'horizontal' } = p;
|
||||
if (orientation === 'vertical') {
|
||||
// UF style: vertical cylinders + cylindrical manifold headers + per-cell inlet valves + MODE readout
|
||||
const cellW = 22, cellH = 130, gap = 10;
|
||||
const w = count * cellW + (count - 1) * gap + 24;
|
||||
const h = cellH + 26;
|
||||
const headerH = 8;
|
||||
const topManifoldY = 6;
|
||||
const botManifoldY = h - 14;
|
||||
let cells = '';
|
||||
let internalFlows = '';
|
||||
let inletValves = '';
|
||||
let outletValves = '';
|
||||
let cellLabels = '';
|
||||
let permeateMarks = '';
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cx = 12 + i * (cellW + gap);
|
||||
const fcx = cx + cellW / 2;
|
||||
cells += `
|
||||
<g id="cell-${id}-${i + 1}" class="membrane-cell" transform="translate(${cx} 12)">
|
||||
<rect x="0" y="0" width="${cellW}" height="${cellH}" rx="${cellW / 2}" fill="url(#metal-v)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect x="3" y="${cellW / 2}" width="${cellW - 6}" height="${cellH - cellW}" rx="${(cellW - 6) / 2}" fill="${accent}" opacity="0.30" class="cell-fluid"/>
|
||||
<line class="cell-fiber" x1="${cellW / 2 - 5}" y1="14" x2="${cellW / 2 - 5}" y2="${cellH - 14}" stroke="#fff" stroke-width="0.5" opacity="0.35" vector-effect="non-scaling-stroke"/>
|
||||
<line class="cell-fiber" x1="${cellW / 2 - 2}" y1="14" x2="${cellW / 2 - 2}" y2="${cellH - 14}" stroke="#fff" stroke-width="0.4" opacity="0.30" vector-effect="non-scaling-stroke"/>
|
||||
<line class="cell-fiber" x1="${cellW / 2 + 2}" y1="14" x2="${cellW / 2 + 2}" y2="${cellH - 14}" stroke="#fff" stroke-width="0.4" opacity="0.30" vector-effect="non-scaling-stroke"/>
|
||||
<line class="cell-fiber" x1="${cellW / 2 + 5}" y1="14" x2="${cellW / 2 + 5}" y2="${cellH - 14}" stroke="#fff" stroke-width="0.5" opacity="0.35" vector-effect="non-scaling-stroke"/>
|
||||
<rect x="2" y="3" width="${cellW - 4}" height="6" rx="1.5" fill="#ff4f9a" opacity="0.85"/>
|
||||
<rect x="2" y="${cellH - 9}" width="${cellW - 4}" height="6" rx="1.5" fill="#ff4f9a" opacity="0.85"/>
|
||||
</g>`;
|
||||
internalFlows += `
|
||||
<line class="membrane-flow flow" x1="${fcx}" y1="14" x2="${fcx}" y2="${cellH + 10}" stroke="${accent}" stroke-width="1.5" stroke-dasharray="3 3" stroke-linecap="round" vector-effect="non-scaling-stroke" fill="none"/>`;
|
||||
// 매니폴드 위에 inlet valve indicator (작은 ball valve symbol)
|
||||
inletValves += `
|
||||
<g class="cell-inlet-valve" transform="translate(${fcx} ${topManifoldY - 1})">
|
||||
<line x1="0" y1="0" x2="0" y2="3" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<circle cx="0" cy="-1.5" r="2" fill="#0b1528" stroke="#aaa" stroke-width="0.6" vector-effect="non-scaling-stroke"/>
|
||||
<line x1="-1.8" y1="-1.5" x2="1.8" y2="-1.5" stroke="#7cff3a" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
</g>`;
|
||||
// 하단 매니폴드 outlet (permeate collection) 위에 작은 stub
|
||||
outletValves += `
|
||||
<g class="cell-outlet-stub" transform="translate(${fcx} ${botManifoldY + headerH + 1})">
|
||||
<line x1="0" y1="0" x2="0" y2="3" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<circle cx="0" cy="3.5" r="1.4" fill="${accent}" stroke="#aaa" stroke-width="0.4" opacity="0.7"/>
|
||||
</g>`;
|
||||
// 모듈 ID (M1~M5) 매니폴드 위쪽 살짝
|
||||
cellLabels += `
|
||||
<text x="${fcx}" y="${topManifoldY - 6}" font-size="4.5" fill="#8590a3" text-anchor="middle">M${i + 1}</text>`;
|
||||
if (i === count - 1) {
|
||||
permeateMarks += `<line x1="${cx + cellW + 2}" y1="${cellH / 2 + 12}" x2="${w - 4}" y2="${cellH / 2 + 12}" stroke="${accent}" stroke-width="1" stroke-dasharray="2 2" opacity="0.6" vector-effect="non-scaling-stroke"/>`;
|
||||
permeateMarks += `<text x="${w - 2}" y="${cellH / 2 + 9}" font-size="6" fill="${accent}" text-anchor="end" opacity="0.85">PERM</text>`;
|
||||
}
|
||||
}
|
||||
// Manifold cylindrical pipe (caps on both ends + bolt rings)
|
||||
const manifoldPipe = (my) => `
|
||||
<g class="manifold-pipe">
|
||||
<rect class="manifold-header" x="2" y="${my}" width="${w - 4}" height="${headerH}" fill="url(#metal-h)" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<ellipse cx="2" cy="${my + headerH / 2}" rx="3.5" ry="${headerH / 2 + 0.5}" fill="url(#metal-v)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<ellipse cx="${w - 2}" cy="${my + headerH / 2}" rx="3.5" ry="${headerH / 2 + 0.5}" fill="url(#metal-v)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<line x1="6" y1="${my + 1}" x2="6" y2="${my + headerH - 1}" stroke="#666" stroke-width="0.4" vector-effect="non-scaling-stroke"/>
|
||||
<line x1="${w-6}" y1="${my + 1}" x2="${w-6}" y2="${my + headerH - 1}" stroke="#666" stroke-width="0.4" vector-effect="non-scaling-stroke"/>
|
||||
</g>`;
|
||||
return `
|
||||
<g class="comp comp-module comp-module-vertical" data-comp-id="${id}" data-comp-type="module" transform="translate(${x} ${y})">
|
||||
<text x="${w / 2}" y="-14" class="comp-label" font-size="11" text-anchor="middle" font-weight="600">${label}</text>
|
||||
<g class="mode-readout" transform="translate(0 -4)">
|
||||
<text id="module-${id}-mode" x="0" y="-1" font-size="6" fill="#7cff3a" font-family="Consolas, monospace">▶ STANDBY</text>
|
||||
</g>
|
||||
<g class="tmp-readout" transform="translate(${w} -4)">
|
||||
<text id="module-${id}-tmp" x="0" y="-1" font-size="6" fill="${accent}" text-anchor="end" font-family="Consolas, monospace">TMP 0.0 bar</text>
|
||||
</g>
|
||||
<rect x="0" y="0" width="${w}" height="${h}" rx="3" fill="none" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
${cellLabels}
|
||||
${manifoldPipe(topManifoldY)}
|
||||
${manifoldPipe(botManifoldY)}
|
||||
${inletValves}
|
||||
${cells}
|
||||
${outletValves}
|
||||
${internalFlows}
|
||||
${permeateMarks}
|
||||
<line id="module-${id}-flow-in" class="module-flow flow" x1="2" y1="${topManifoldY + headerH / 2}" x2="${w - 2}" y2="${topManifoldY + headerH / 2}" stroke="${accent}" stroke-width="2" stroke-dasharray="4 4" stroke-linecap="round" vector-effect="non-scaling-stroke" fill="none"/>
|
||||
<line id="module-${id}-flow-out" class="module-flow flow" x1="2" y1="${botManifoldY + headerH / 2}" x2="${w - 2}" y2="${botManifoldY + headerH / 2}" stroke="${accent}" stroke-width="2" stroke-dasharray="4 4" stroke-linecap="round" vector-effect="non-scaling-stroke" fill="none"/>
|
||||
</g>`;
|
||||
}
|
||||
// RO style: horizontal cylinders stacked vertically
|
||||
const cellW = 180, cellH = 16, gap = 8;
|
||||
const w = cellW + 28;
|
||||
const h = count * cellH + (count - 1) * gap + 24;
|
||||
let cells = '';
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cy = 12 + i * (cellH + gap);
|
||||
cells += `
|
||||
<g id="cell-${id}-${i + 1}" class="membrane-cell" transform="translate(14 ${cy})">
|
||||
<rect x="0" y="0" width="${cellW}" height="${cellH}" rx="${cellH / 2}" fill="url(#metal-h)" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect x="12" y="3" width="${cellW - 24}" height="${cellH - 6}" rx="${(cellH - 6) / 2}" fill="${accent}" opacity="0.25" class="cell-fluid"/>
|
||||
<rect x="3" y="2" width="5" height="${cellH - 4}" rx="1" fill="#ff4f9a" opacity="0.85"/>
|
||||
<rect x="${cellW - 8}" y="2" width="5" height="${cellH - 4}" rx="1" fill="#ff4f9a" opacity="0.85"/>
|
||||
</g>`;
|
||||
}
|
||||
return `
|
||||
<g class="comp comp-module" data-comp-id="${id}" data-comp-type="module" transform="translate(${x} ${y})">
|
||||
<text x="${w / 2}" y="-4" class="comp-label" font-size="10" text-anchor="middle">${label}</text>
|
||||
<rect x="0" y="0" width="${w}" height="${h}" rx="3" fill="none" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
${cells}
|
||||
<line id="module-${id}-flow-in" class="module-flow flow" x1="0" y1="${h / 2}" x2="10" y2="${h / 2}" stroke="${accent}" stroke-width="2" stroke-dasharray="4 4" stroke-linecap="round" vector-effect="non-scaling-stroke" fill="none"/>
|
||||
<line id="module-${id}-flow-out" class="module-flow flow" x1="${w - 10}" y1="${h / 2}" x2="${w}" y2="${h / 2}" stroke="${accent}" stroke-width="2" stroke-dasharray="4 4" stroke-linecap="round" vector-effect="non-scaling-stroke" fill="none"/>
|
||||
</g>`;
|
||||
};
|
||||
|
||||
// ===== CARBON CARTRIDGE BANK (vertical, MBR style) =====
|
||||
COMP.carbonBank = function (p) {
|
||||
const { id, x, y, count = 5, label = 'CARBON BANK' } = p;
|
||||
const cellW = 14, gap = 5, cellH = 64;
|
||||
const w = count * cellW + (count - 1) * gap + 16;
|
||||
const h = cellH + 32;
|
||||
let cells = '';
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cx = 8 + i * (cellW + gap);
|
||||
cells += `
|
||||
<g id="cartridge-${id}-${i + 1}" class="cartridge" transform="translate(${cx} 24)">
|
||||
<rect class="cartridge-shell" x="0" y="0" width="${cellW}" height="${cellH}" rx="6" fill="url(#metal-v)" stroke="#aaa" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect class="cartridge-fluid" x="3" y="8" width="${cellW - 6}" height="${cellH - 16}" rx="3" fill="#5af9ff" opacity="0.25"/>
|
||||
<rect x="1" y="2" width="${cellW - 2}" height="4" rx="1" fill="#11203a" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect x="1" y="${cellH - 6}" width="${cellW - 2}" height="4" rx="1" fill="#11203a" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
</g>`;
|
||||
}
|
||||
return `
|
||||
<g class="comp comp-module comp-carbon" data-comp-id="${id}" data-comp-type="module" transform="translate(${x} ${y})">
|
||||
<text x="${w / 2}" y="-3" class="comp-label" font-size="9" text-anchor="middle">${label}</text>
|
||||
${cells}
|
||||
<line id="module-${id}-flow-in" class="module-flow flow" x1="2" y1="16" x2="${w - 2}" y2="16" stroke="#5af9ff" stroke-width="2" stroke-dasharray="4 4" stroke-linecap="round" vector-effect="non-scaling-stroke" fill="none"/>
|
||||
</g>`;
|
||||
};
|
||||
|
||||
// ===== STATUS TILE (KPI mini panel) =====
|
||||
COMP.statusTile = function (p) {
|
||||
const { id, x, y, label = 'TILE', value = '--', unit = '', w = 130, h = 50 } = p;
|
||||
return `
|
||||
<g class="comp comp-tile" data-comp-id="${id}" data-comp-type="tile" transform="translate(${x} ${y})">
|
||||
<rect x="0" y="0" width="${w}" height="${h}" rx="3" fill="#071326" stroke="#fff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<text x="6" y="13" class="tile-label" font-size="8">${label}</text>
|
||||
<text id="tile-${id}-value" x="${w - 6}" y="32" class="tile-value" font-size="18" text-anchor="end">${value}</text>
|
||||
<text x="${w - 6}" y="44" class="tile-unit" font-size="8" text-anchor="end">${unit}</text>
|
||||
</g>`;
|
||||
};
|
||||
|
||||
// ===== PANEL FRAME (외곽 그룹 박스) =====
|
||||
COMP.panel = function (p) {
|
||||
const { id, x, y, w, h, label = '' } = p;
|
||||
return `
|
||||
<g class="comp comp-panel" data-comp-id="${id}" data-comp-type="panel" transform="translate(${x} ${y})">
|
||||
<rect x="0" y="0" width="${w}" height="${h}" rx="3" fill="none" stroke="#ff8a3a" stroke-width="1" stroke-dasharray="4 2" vector-effect="non-scaling-stroke"/>
|
||||
${label ? `<rect x="6" y="-8" width="${label.length * 6 + 12}" height="14" rx="2" fill="#050a18" stroke="#ff8a3a" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<text x="12" y="2" class="panel-label" font-size="9">${label}</text>` : ''}
|
||||
</g>`;
|
||||
};
|
||||
|
||||
global.INVYONE_COMP = COMP;
|
||||
})(window);
|
||||
@@ -0,0 +1,539 @@
|
||||
// INVYONE Stage-2 — Runtime Engine
|
||||
// state + 250ms tick loop + flow/pressure/alarm 시뮬레이션
|
||||
// PLC 연결 없는 데모용. 모든 컴포넌트가 독립 제어 가능 (샌드박스).
|
||||
|
||||
(function (global) {
|
||||
const TICK_MS = 250;
|
||||
const state = {
|
||||
tanks: {}, pumps: {}, valves: {}, sensors: {}, gauges: {}, cartridges: {},
|
||||
events: [], paused: false, flowMultiplier: 5.0, currentMode: 'STOP'
|
||||
};
|
||||
let tickId = null;
|
||||
let T = null;
|
||||
|
||||
// ============================================================
|
||||
// INIT
|
||||
// ============================================================
|
||||
function init() {
|
||||
T = global.INVYONE_TOPO;
|
||||
T.TANKS.forEach(t => state.tanks[t.id] = Object.assign({}, t, { alarmState: null }));
|
||||
T.PUMPS.forEach(p => state.pumps[p.id] = Object.assign({}, p, { running: false, rpm: 1450, runtime: 0 }));
|
||||
T.VALVES.forEach(v => state.valves[v.id] = Object.assign({}, v));
|
||||
T.SENSORS.forEach(s => state.sensors[s.id] = Object.assign({}, s, { alarmState: null }));
|
||||
T.GAUGES.forEach(g => state.gauges[g.id] = Object.assign({}, g, { value: 0 }));
|
||||
T.MODULES.forEach(m => {
|
||||
if (m.type === 'carbon') {
|
||||
for (let i = 1; i <= m.count; i++) state.cartridges[`${m.id}-${i}`] = { isolated: false };
|
||||
}
|
||||
});
|
||||
Object.keys(state.tanks).forEach(setTankVisual);
|
||||
Object.keys(state.pumps).forEach(setPumpVisual);
|
||||
Object.keys(state.valves).forEach(setValveVisual);
|
||||
Object.keys(state.sensors).forEach(setSensorVisual);
|
||||
Object.keys(state.gauges).forEach(setGaugeVisual);
|
||||
updateRoutes();
|
||||
updateTiles();
|
||||
emitEvent('INVYONE Stage-2 데모 준비 완료', 'info');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// VISUAL UPDATERS
|
||||
// ============================================================
|
||||
function tankDef(id) { return T.TANKS.find(t => t.id === id); }
|
||||
|
||||
function setTankVisual(id) {
|
||||
const t = state.tanks[id];
|
||||
if (!t) return;
|
||||
const def = tankDef(id);
|
||||
const pct = clamp(t.level, 0, 100);
|
||||
|
||||
let yMin, yMax;
|
||||
if (def.type === 'ac') { yMin = 26; yMax = def.h - 12; } // cone(22)+pad(4) … h-skirt(8)-pad(4)
|
||||
else if (def.type === 'dosing') { yMin = 2; yMax = def.h - 2; }
|
||||
else { yMin = 3; yMax = def.h - 3; }
|
||||
const fullH = yMax - yMin;
|
||||
const liquidH = fullH * pct / 100;
|
||||
const yLiq = yMax - liquidH;
|
||||
|
||||
const liq = qid(`tank-${id}-liquid`);
|
||||
if (liq) { liq.setAttribute('y', yLiq.toFixed(2)); liq.setAttribute('height', liquidH.toFixed(2)); }
|
||||
const surf = qid(`tank-${id}-surface`);
|
||||
if (surf) { surf.setAttribute('cy', yLiq.toFixed(2)); surf.style.opacity = pct > 0 ? 0.85 : 0; }
|
||||
const text = qid(`tank-${id}-text`);
|
||||
if (text) text.textContent = pct.toFixed(1) + '%';
|
||||
|
||||
// Alarm classification
|
||||
const a = def.alarms || {};
|
||||
let alarm = null;
|
||||
if (a.hh != null && pct >= a.hh) alarm = 'hh';
|
||||
else if (a.hl != null && pct >= a.hl) alarm = 'hl';
|
||||
else if (a.ll != null && pct <= a.ll) alarm = 'll';
|
||||
else if (a.lh != null && pct <= a.lh) alarm = 'lh';
|
||||
if (t.alarmState !== alarm) {
|
||||
const prev = t.alarmState;
|
||||
t.alarmState = alarm;
|
||||
if (alarm) emitEvent(`${def.label}: ${alarm.toUpperCase()} 알람 (${pct.toFixed(1)}%)`, 'alarm');
|
||||
else if (prev) emitEvent(`${def.label}: 정상 복귀 (${pct.toFixed(1)}%)`, 'info');
|
||||
}
|
||||
const bg = qid(`tank-${id}-level-bg`);
|
||||
if (bg) {
|
||||
bg.setAttribute('fill', alarm === 'hh' || alarm === 'll' ? '#5a102c' : alarm === 'hl' || alarm === 'lh' ? '#4a3a10' : '#000');
|
||||
bg.setAttribute('stroke', alarm === 'hh' || alarm === 'll' ? '#ff4f9a' : alarm === 'hl' || alarm === 'lh' ? '#ff8a3a' : '#fff');
|
||||
}
|
||||
const root = qsel(`[data-comp-id="${id}"][data-comp-type="tank"]`);
|
||||
if (root) {
|
||||
root.classList.toggle('alarm-hh', alarm === 'hh');
|
||||
root.classList.toggle('alarm-hl', alarm === 'hl');
|
||||
root.classList.toggle('alarm-ll', alarm === 'll');
|
||||
root.classList.toggle('alarm-lh', alarm === 'lh');
|
||||
}
|
||||
}
|
||||
|
||||
function setPumpVisual(id) {
|
||||
const p = state.pumps[id];
|
||||
if (!p) return;
|
||||
const root = qsel(`[data-comp-id="${id}"][data-comp-type="pump"]`);
|
||||
if (root) root.classList.toggle('running', p.running);
|
||||
const led = qid(`pump-${id}-led`);
|
||||
if (led) led.setAttribute('fill', p.running ? '#7cff3a' : '#2a3f5a');
|
||||
}
|
||||
|
||||
function setValveVisual(id) {
|
||||
const v = state.valves[id];
|
||||
if (!v) return;
|
||||
const root = qsel(`[data-comp-id="${id}"][data-comp-type="valve"]`);
|
||||
if (root) {
|
||||
root.classList.toggle('open', !!v.open);
|
||||
root.classList.toggle('closed', !v.open);
|
||||
}
|
||||
// handle rotation driven by CSS .closed class
|
||||
const body = qid(`valve-${id}-body`);
|
||||
if (body) body.setAttribute('fill', v.open ? '#082a18' : '#071326');
|
||||
}
|
||||
|
||||
function setSensorVisual(id) {
|
||||
const s = state.sensors[id];
|
||||
if (!s) return;
|
||||
const valEl = qid(`sensor-${id}-value`);
|
||||
if (valEl) valEl.textContent = (typeof s.value === 'number' ? s.value.toFixed(2) : s.value);
|
||||
let alarm = null;
|
||||
if (s.alarms) {
|
||||
if (s.value < s.alarms.lo) alarm = 'lo';
|
||||
else if (s.value > s.alarms.hi) alarm = 'hi';
|
||||
}
|
||||
if (s.alarmState !== alarm) {
|
||||
const prev = s.alarmState;
|
||||
s.alarmState = alarm;
|
||||
if (alarm) emitEvent(`센서 ${s.label}: ${alarm.toUpperCase()} 임계 (${typeof s.value === 'number' ? s.value.toFixed(2) : s.value} ${s.unit})`, 'alarm');
|
||||
else if (prev) emitEvent(`센서 ${s.label}: 정상 복귀`, 'info');
|
||||
}
|
||||
const root = qsel(`[data-comp-id="${id}"][data-comp-type="sensor"]`);
|
||||
if (root) root.classList.toggle('alarm', !!alarm);
|
||||
}
|
||||
|
||||
function setGaugeVisual(id) {
|
||||
const g = state.gauges[id];
|
||||
if (!g) return;
|
||||
const max = g.max || 8;
|
||||
const pct = clamp((g.value / max) * 100, 0, 100);
|
||||
const angle = -120 + (240 * pct / 100);
|
||||
const needle = qid(`gauge-${id}-needle`);
|
||||
if (needle) needle.setAttribute('transform', `rotate(${angle.toFixed(2)} 40 40)`);
|
||||
const text = qid(`gauge-${id}-text`);
|
||||
if (text) text.textContent = g.value.toFixed(1);
|
||||
const bg = qid(`gauge-${id}-bg`);
|
||||
if (bg) bg.setAttribute('fill', pct > 75 ? '#3a0e1e' : pct > 50 ? '#3a2a0e' : '#081326');
|
||||
const root = qsel(`[data-comp-id="${id}"][data-comp-type="gauge"]`);
|
||||
if (root) root.classList.toggle('alarm', pct > 85);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ROUTING / FLOW
|
||||
// ============================================================
|
||||
function pumpRouteActive(p) {
|
||||
if (!p.running) return false;
|
||||
for (const vid of (p.valves || [])) {
|
||||
const v = state.valves[vid];
|
||||
if (v && !v.open) return false;
|
||||
}
|
||||
if (p.source) {
|
||||
const src = state.tanks[p.source];
|
||||
if (!src || src.level <= 0.05) return false;
|
||||
}
|
||||
if (p.dest) {
|
||||
const dst = state.tanks[p.dest];
|
||||
if (dst && dst.level >= 99.95) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Track pump activation state across ticks for gradual pipe-fill animation.
|
||||
const prevPumpActive = {};
|
||||
const activePumpsForPipe = {}; // { pipeId: Set<pumpId> } — OR-accumulated activity
|
||||
const PIPE_FILL_DELAY = 120; // ms between successive pipes in a pump's chain
|
||||
|
||||
function activatePipesGradually(pumpId) {
|
||||
const pump = state.pumps[pumpId];
|
||||
if (!pump) return;
|
||||
(pump.pipes || []).forEach((pid, i) => {
|
||||
setTimeout(() => {
|
||||
// pump may have been turned OFF during the wait — re-check
|
||||
if (!pumpRouteActive(state.pumps[pumpId])) return;
|
||||
activePumpsForPipe[pid] = activePumpsForPipe[pid] || new Set();
|
||||
activePumpsForPipe[pid].add(pumpId);
|
||||
const flowLine = qid(`pipe-${pid}-flow`);
|
||||
const pipeRoot = qsel(`[data-comp-id="${pid}"][data-comp-type="pipe"]`);
|
||||
if (flowLine) { flowLine.classList.add('active'); flowLine.classList.remove('blocked'); }
|
||||
if (pipeRoot) pipeRoot.classList.add('active');
|
||||
}, i * PIPE_FILL_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
function deactivatePipes(pumpId) {
|
||||
const pump = state.pumps[pumpId];
|
||||
if (!pump) return;
|
||||
(pump.pipes || []).forEach(pid => {
|
||||
if (activePumpsForPipe[pid]) {
|
||||
activePumpsForPipe[pid].delete(pumpId);
|
||||
if (activePumpsForPipe[pid].size === 0) {
|
||||
const flowLine = qid(`pipe-${pid}-flow`);
|
||||
const pipeRoot = qsel(`[data-comp-id="${pid}"][data-comp-type="pipe"]`);
|
||||
if (flowLine) flowLine.classList.remove('active');
|
||||
if (pipeRoot) pipeRoot.classList.remove('active');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateRoutes() {
|
||||
// Reset every tick — clean slate, then recompute from authoritative state
|
||||
document.querySelectorAll('.valve-flow').forEach(el => el.classList.remove('active', 'blocked'));
|
||||
document.querySelectorAll('.pipe-flow').forEach(el => el.classList.remove('blocked'));
|
||||
|
||||
// Compute the authoritative set of active pipes from current pump state
|
||||
const activePipeSet = new Set();
|
||||
const blockedPipeSet = new Set();
|
||||
Object.values(state.pumps).forEach(p => {
|
||||
if (pumpRouteActive(p)) {
|
||||
(p.pipes || []).forEach(pid => activePipeSet.add(pid));
|
||||
} else if (p.running) {
|
||||
(p.pipes || []).forEach(pid => blockedPipeSet.add(pid));
|
||||
}
|
||||
});
|
||||
|
||||
// Sync activePumpsForPipe with reality — drop entries whose pumps are no longer active
|
||||
Object.keys(activePumpsForPipe).forEach(pid => {
|
||||
const set = activePumpsForPipe[pid];
|
||||
[...set].forEach(pumpId => {
|
||||
if (!pumpRouteActive(state.pumps[pumpId])) set.delete(pumpId);
|
||||
});
|
||||
});
|
||||
|
||||
// Drain pipes whose set went empty
|
||||
document.querySelectorAll('[data-comp-type="pipe"].active').forEach(el => {
|
||||
const id = el.getAttribute('data-comp-id');
|
||||
const set = activePumpsForPipe[id];
|
||||
if (!set || set.size === 0) {
|
||||
if (!activePipeSet.has(id)) {
|
||||
el.classList.remove('active');
|
||||
const flowLine = qid(`pipe-${id}-flow`);
|
||||
if (flowLine) flowLine.classList.remove('active');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Per-pump triggers: detect new ON to start gradual fill
|
||||
Object.values(state.pumps).forEach(p => {
|
||||
const active = pumpRouteActive(p);
|
||||
const wasActive = !!prevPumpActive[p.id];
|
||||
if (active && !wasActive) activatePipesGradually(p.id);
|
||||
|
||||
// valve flow follows pump active state (immediate)
|
||||
if (active) {
|
||||
(p.valves || []).forEach(vid => {
|
||||
const v = state.valves[vid];
|
||||
if (v && v.open) {
|
||||
const fl = qid(`valve-${vid}-flow-l`);
|
||||
const fr = qid(`valve-${vid}-flow-r`);
|
||||
if (fl) fl.classList.add('active');
|
||||
if (fr) fr.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
prevPumpActive[p.id] = active;
|
||||
});
|
||||
|
||||
// Apply blocked state (running but route incomplete) — never on active pipes
|
||||
blockedPipeSet.forEach(pid => {
|
||||
if (activePipeSet.has(pid)) return;
|
||||
const flowLine = qid(`pipe-${pid}-flow`);
|
||||
if (flowLine) flowLine.classList.add('blocked');
|
||||
});
|
||||
|
||||
// Module flow lines: if any pump route reaches them, mark active (heuristic by id matching)
|
||||
document.querySelectorAll('[data-comp-type="module"]').forEach(modEl => {
|
||||
const id = modEl.getAttribute('data-comp-id');
|
||||
let active = false;
|
||||
if (id === 'uf-system') {
|
||||
active = pumpRouteActive(state.pumps['uf-pump-a']) || pumpRouteActive(state.pumps['uf-pump-b']);
|
||||
} else if (id === 'mbr-carb-l' || id === 'mbr-carb-r') {
|
||||
// Carbon banks "active" while water flows through MBR (RO press feeding it OR aer-drain pulling it)
|
||||
active = pumpRouteActive(state.pumps['ro-press']) || pumpRouteActive(state.pumps['aer-drain']);
|
||||
} else if (id === 'ro-system') {
|
||||
active = pumpRouteActive(state.pumps['ro-feed']) || pumpRouteActive(state.pumps['ro-press']) || pumpRouteActive(state.pumps['cip-pump']);
|
||||
}
|
||||
modEl.classList.toggle('active', active);
|
||||
modEl.querySelectorAll('.flow').forEach(el => el.classList.toggle('active', active));
|
||||
});
|
||||
|
||||
// MBR aeration: tank shell glows when AIR BLOWER is running
|
||||
const mbrEl = document.querySelector('[data-comp-id="mbr"][data-comp-type="tank"]');
|
||||
if (mbrEl) {
|
||||
const blower = state.pumps['air-blower'];
|
||||
mbrEl.classList.toggle('aerating', !!(blower && blower.running));
|
||||
}
|
||||
|
||||
// Inline filters: active when any feed pump is delivering through them
|
||||
const filterFeed = {
|
||||
'pump-filter': ['bw-a1'],
|
||||
'uf-filter': ['uf-pump-a', 'uf-pump-b'],
|
||||
'ro-filter': ['ro-feed', 'ro-press'],
|
||||
'cip-filter': ['cip-pump'],
|
||||
};
|
||||
Object.keys(filterFeed).forEach(fid => {
|
||||
const el = document.querySelector(`[data-comp-id="${fid}"][data-comp-type="filter"]`);
|
||||
if (!el) return;
|
||||
const active = filterFeed[fid].some(pid => {
|
||||
const pp = state.pumps[pid];
|
||||
return pp && pumpRouteActive(pp);
|
||||
});
|
||||
el.classList.toggle('active', active);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// TICK LOOP
|
||||
// ============================================================
|
||||
function tick() {
|
||||
if (state.paused) return;
|
||||
|
||||
Object.values(state.pumps).forEach(p => {
|
||||
if (!pumpRouteActive(p)) return;
|
||||
const lpm = (p.lpm || 0) * (p.rpm / 1450) * state.flowMultiplier;
|
||||
const liters = lpm / 60 * (TICK_MS / 1000);
|
||||
|
||||
if (p.source) {
|
||||
const src = state.tanks[p.source];
|
||||
if (src) src.level = Math.max(0, src.level - (liters / src.capacity * 100));
|
||||
}
|
||||
if (p.dest) {
|
||||
const dst = state.tanks[p.dest];
|
||||
if (dst) dst.level = Math.min(100, dst.level + (liters / dst.capacity * 100));
|
||||
}
|
||||
p.runtime += TICK_MS / 1000;
|
||||
});
|
||||
|
||||
Object.values(state.gauges).forEach(g => {
|
||||
// 시나리오가 게이지를 강제 설정한 경우 자동 갱신 skip (압력 알람값 보존)
|
||||
if (g.forced) return;
|
||||
const srcPump = g.source ? state.pumps[g.source] : null;
|
||||
let target = 0;
|
||||
if (srcPump && srcPump.running) {
|
||||
const ratio = pumpRouteActive(srcPump) ? 0.55 : 0.85; // blocked → higher pressure
|
||||
target = (g.max || 8) * ratio + (Math.random() - 0.5) * 0.2;
|
||||
} else {
|
||||
target = (Math.random() - 0.5) * 0.05;
|
||||
}
|
||||
g.value = g.value * 0.85 + target * 0.15;
|
||||
if (g.value < 0) g.value = 0;
|
||||
});
|
||||
|
||||
Object.values(state.sensors).forEach(s => {
|
||||
if (typeof s.value !== 'number') return;
|
||||
// very gentle noise so manually-set values stay near their target
|
||||
const noise = (Math.random() - 0.5) * 0.0015 * (s.max - s.min);
|
||||
s.value = clamp(s.value + noise, s.min, s.max);
|
||||
});
|
||||
|
||||
Object.keys(state.tanks).forEach(setTankVisual);
|
||||
Object.keys(state.gauges).forEach(setGaugeVisual);
|
||||
Object.keys(state.sensors).forEach(setSensorVisual);
|
||||
updateModuleReadouts();
|
||||
updateTiles();
|
||||
updateRoutes();
|
||||
}
|
||||
|
||||
function updateModuleReadouts() {
|
||||
const ufIn = state.gauges['p-uf-1'];
|
||||
const ufOut = state.gauges['p-uf-2'];
|
||||
const tmpEl = qid('module-uf-system-tmp');
|
||||
if (tmpEl) {
|
||||
const tmp = (ufIn && ufOut) ? Math.max(0, ufIn.value - ufOut.value) : 0;
|
||||
tmpEl.textContent = `TMP ${tmp.toFixed(2)} bar`;
|
||||
}
|
||||
const ufModeEl = qid('module-uf-system-mode');
|
||||
const ufRoot = qsel('[data-comp-id="uf-system"][data-comp-type="module"]');
|
||||
if (ufModeEl) {
|
||||
const m = state.currentMode;
|
||||
let modeText = 'STANDBY', color = '#7cff3a';
|
||||
if (m === 'NORMAL') { modeText = 'FILTRATION'; color = '#5af9ff'; }
|
||||
else if (m === 'BACKWASH') { modeText = 'BACKWASH'; color = '#ff8a3a'; }
|
||||
else if (m === 'CIP') { modeText = 'CIP'; color = '#ff4f9a'; }
|
||||
else if (m === 'CHEM-DOSE'){ modeText = 'CHEM DOSE'; color = '#7cff3a'; }
|
||||
ufModeEl.textContent = `▶ ${modeText}`;
|
||||
ufModeEl.setAttribute('fill', color);
|
||||
}
|
||||
if (ufRoot) {
|
||||
ufRoot.classList.toggle('mode-backwash', state.currentMode === 'BACKWASH');
|
||||
ufRoot.classList.toggle('mode-cip', state.currentMode === 'CIP');
|
||||
}
|
||||
}
|
||||
|
||||
function updateTiles() {
|
||||
let flowIn = 0, flowOut = 0, alarmCount = 0;
|
||||
Object.values(state.pumps).forEach(p => {
|
||||
if (pumpRouteActive(p)) {
|
||||
const lpm = (p.lpm || 0) * (p.rpm / 1450);
|
||||
if (!p.source) flowIn += lpm;
|
||||
if (p.dest === 'regulating') flowOut += lpm;
|
||||
}
|
||||
});
|
||||
Object.values(state.tanks).forEach(t => { if (t.alarmState) alarmCount++; });
|
||||
Object.values(state.sensors).forEach(s => { if (s.alarmState) alarmCount++; });
|
||||
setTileVal('flow-in', flowIn.toFixed(1));
|
||||
setTileVal('flow-out', flowOut.toFixed(1));
|
||||
setTileVal('recovery', flowIn > 0 ? clamp(flowOut / flowIn * 100, 0, 100).toFixed(1) : '0.0');
|
||||
setTileVal('alarms', String(alarmCount));
|
||||
const alarmsTile = qsel('[data-comp-id="alarms"][data-comp-type="tile"]');
|
||||
if (alarmsTile) alarmsTile.classList.toggle('alarm', alarmCount > 0);
|
||||
}
|
||||
function setTileVal(id, v) { const el = qid(`tile-${id}-value`); if (el) el.textContent = v; }
|
||||
|
||||
// ============================================================
|
||||
// EVENTS / LOG
|
||||
// ============================================================
|
||||
function emitEvent(msg, level) {
|
||||
level = level || 'info';
|
||||
const time = new Date().toLocaleTimeString('ko-KR', { hour12: false });
|
||||
state.events.unshift({ time, msg, level });
|
||||
if (state.events.length > 200) state.events.pop();
|
||||
const log = document.getElementById('event-log');
|
||||
if (!log) return;
|
||||
const line = document.createElement('div');
|
||||
line.className = `event-line event-${level}`;
|
||||
line.textContent = `[${time}] ${msg}`;
|
||||
log.insertBefore(line, log.firstChild);
|
||||
while (log.children.length > 200) log.removeChild(log.lastChild);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PUBLIC ACTIONS
|
||||
// ============================================================
|
||||
function togglePump(id) {
|
||||
const p = state.pumps[id]; if (!p) return;
|
||||
p.running = !p.running; setPumpVisual(id); updateRoutes();
|
||||
emitEvent(`PUMP ${p.label || id}: ${p.running ? 'ON' : 'OFF'}`, p.running ? 'info' : 'warn');
|
||||
}
|
||||
function toggleValve(id) {
|
||||
const v = state.valves[id]; if (!v) return;
|
||||
v.open = !v.open; setValveVisual(id); updateRoutes();
|
||||
emitEvent(`VALVE ${v.label || id}: ${v.open ? 'OPEN' : 'CLOSED'}`, 'info');
|
||||
}
|
||||
function setTankLevel(id, pct) {
|
||||
const t = state.tanks[id]; if (!t) return;
|
||||
t.level = clamp(pct, 0, 100); setTankVisual(id);
|
||||
}
|
||||
function setSensor(id, value) {
|
||||
const s = state.sensors[id]; if (!s) return;
|
||||
s.value = value; setSensorVisual(id);
|
||||
}
|
||||
function setRpm(id, rpm) {
|
||||
const p = state.pumps[id]; if (!p) return;
|
||||
p.rpm = clamp(rpm, 0, 3000);
|
||||
}
|
||||
function isolateCartridge(cid) {
|
||||
const c = state.cartridges[cid]; if (!c) return;
|
||||
c.isolated = !c.isolated;
|
||||
const el = document.getElementById(`cartridge-${cid}`);
|
||||
if (el) el.classList.toggle('isolated', c.isolated);
|
||||
emitEvent(`카트리지 ${cid}: ${c.isolated ? '격리(BYPASS)' : '복귀'}`, c.isolated ? 'warn' : 'info');
|
||||
}
|
||||
|
||||
function applyMode(mode) {
|
||||
state.currentMode = mode;
|
||||
const setVO = (vid, open) => { if (state.valves[vid]) state.valves[vid].open = open; };
|
||||
const setPR = (pid, run) => { if (state.pumps[pid]) state.pumps[pid].running = run; };
|
||||
|
||||
if (mode === 'NORMAL') {
|
||||
['v-input','v-sand-out','v-ac-out','v-raw-out','v-uf-in','v-uf-out','v-ro-in','v-ro-press-out','v-mbr-out','v-dp-out']
|
||||
.forEach(v => setVO(v, true));
|
||||
['v-chem-uf','v-cip-out'].forEach(v => setVO(v, false));
|
||||
['bw-a1','sand-out','ac-out','bw-a2','uf-pump-a','ro-feed','ro-press','aer-drain','dp-out','air-blower']
|
||||
.forEach(p => setPR(p, true));
|
||||
['uf-pump-b','cip-pump','chem-uf'].forEach(p => setPR(p, false));
|
||||
} else if (mode === 'BACKWASH') {
|
||||
Object.keys(state.valves).forEach(v => setVO(v, false));
|
||||
Object.keys(state.pumps).forEach(p => setPR(p, false));
|
||||
['v-input','v-uf-in','v-uf-out'].forEach(v => setVO(v, true));
|
||||
['bw-a1','bw-a2'].forEach(p => setPR(p, true));
|
||||
} else if (mode === 'CIP') {
|
||||
Object.keys(state.valves).forEach(v => setVO(v, false));
|
||||
Object.keys(state.pumps).forEach(p => setPR(p, false));
|
||||
['v-cip-out','v-ro-in'].forEach(v => setVO(v, true));
|
||||
['cip-pump'].forEach(p => setPR(p, true));
|
||||
} else if (mode === 'CHEM-DOSE') {
|
||||
// start chemical dosing into UF
|
||||
setVO('v-chem-uf', true);
|
||||
setPR('chem-uf', true);
|
||||
} else if (mode === 'STOP') {
|
||||
Object.keys(state.valves).forEach(v => setVO(v, false));
|
||||
Object.keys(state.pumps).forEach(p => setPR(p, false));
|
||||
}
|
||||
Object.keys(state.valves).forEach(setValveVisual);
|
||||
Object.keys(state.pumps).forEach(setPumpVisual);
|
||||
updateRoutes();
|
||||
updateModuleReadouts();
|
||||
emitEvent(`운전모드 변경: ${mode}`, 'info');
|
||||
const modeEl = document.getElementById('current-mode');
|
||||
if (modeEl) modeEl.textContent = mode;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
T.TANKS.forEach(t => state.tanks[t.id].level = t.level);
|
||||
Object.keys(state.tanks).forEach(setTankVisual);
|
||||
applyMode('STOP');
|
||||
state.events.length = 0;
|
||||
const log = document.getElementById('event-log');
|
||||
if (log) log.innerHTML = '';
|
||||
emitEvent('SYSTEM RESET — 초기 데모 상태로 복구', 'info');
|
||||
}
|
||||
|
||||
function triggerDemoAlarm() {
|
||||
state.tanks['raw'].level = 96;
|
||||
state.tanks['water-filter'].level = 4;
|
||||
state.sensors['ph'].value = 9.2;
|
||||
setTankVisual('raw');
|
||||
setTankVisual('water-filter');
|
||||
setSensorVisual('ph');
|
||||
emitEvent('데모 알람 트리거: RAW HH + WATER FILTER LL + pH HI', 'alarm');
|
||||
}
|
||||
|
||||
function start() { if (!tickId) tickId = setInterval(tick, TICK_MS); }
|
||||
function stop() { if (tickId) { clearInterval(tickId); tickId = null; } }
|
||||
|
||||
// ============================================================
|
||||
// HELPERS
|
||||
// ============================================================
|
||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||||
function qid(id) { return document.getElementById(id); }
|
||||
function qsel(s) { return document.querySelector(s); }
|
||||
|
||||
global.INVYONE_ENGINE = {
|
||||
state, init, start, stop, tick,
|
||||
togglePump, toggleValve, setTankLevel, setSensor, setRpm, isolateCartridge,
|
||||
applyMode, reset, triggerDemoAlarm, emitEvent,
|
||||
setTankVisual, setPumpVisual, setValveVisual, setSensorVisual, setGaugeVisual,
|
||||
updateRoutes
|
||||
};
|
||||
})(window);
|
||||
@@ -0,0 +1,44 @@
|
||||
// INVYONE Stage-2 — Bootstrap
|
||||
// 1) 토폴로지에서 SVG 씬 생성 2) 엔진 초기화 3) UI 와이어업 4) tick 시작
|
||||
|
||||
(function () {
|
||||
function boot() {
|
||||
const sceneEl = document.getElementById('scene-content');
|
||||
if (!sceneEl) {
|
||||
console.error('#scene-content 노드를 찾을 수 없음');
|
||||
return;
|
||||
}
|
||||
sceneEl.innerHTML = window.INVYONE_TOPO.buildScene();
|
||||
|
||||
window.INVYONE_ENGINE.init();
|
||||
window.INVYONE_UI.init();
|
||||
window.INVYONE_ENGINE.start();
|
||||
|
||||
// expose for browser console
|
||||
window.INVYONE = {
|
||||
engine: window.INVYONE_ENGINE,
|
||||
topo: window.INVYONE_TOPO,
|
||||
ui: window.INVYONE_UI,
|
||||
scenario: window.INVYONE_SCENARIO,
|
||||
// 빠른 콘솔 데모 헬퍼
|
||||
help() {
|
||||
console.log('%cINVYONE Stage-2 Demo', 'color:#5af9ff;font-size:14px');
|
||||
console.log('console 사용법:');
|
||||
console.log(' INVYONE.engine.applyMode("NORMAL"|"BACKWASH"|"CIP"|"STOP"|"CHEM-DOSE")');
|
||||
console.log(' INVYONE.engine.togglePump("uf-pump-a")');
|
||||
console.log(' INVYONE.engine.toggleValve("v-uf-in")');
|
||||
console.log(' INVYONE.engine.setTankLevel("raw", 90)');
|
||||
console.log(' INVYONE.engine.setSensor("ph", 9.5)');
|
||||
console.log(' INVYONE.engine.triggerDemoAlarm()');
|
||||
console.log(' INVYONE.engine.reset()');
|
||||
console.log(' INVYONE.scenario.play("bw-a1-overpressure") — 경고시스템 시연');
|
||||
console.log(' INVYONE.scenario.stop()');
|
||||
console.log(' INVYONE.engine.state — 전체 상태 객체');
|
||||
}
|
||||
};
|
||||
console.log('%cINVYONE Stage-2 Ready — INVYONE.help() 입력', 'color:#7cff3a');
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
|
||||
else boot();
|
||||
})();
|
||||
@@ -0,0 +1,250 @@
|
||||
// INVYONE Stage-2 — Emergency Scenario Player
|
||||
// 시연용 자동 시나리오 시퀀서: 경고 발생 → 모달(CCTV+담당자) → 핸드폰 알림 → 회사맵 → 담당자 이동 → ACK → 복귀
|
||||
// 각 step은 { at: ms, action: fn } 형식. start() 호출 시 setTimeout chain으로 자동 진행.
|
||||
|
||||
(function (global) {
|
||||
// ============================================================
|
||||
// STATE
|
||||
// ============================================================
|
||||
const state = {
|
||||
running: false,
|
||||
paused: false,
|
||||
timers: [], // 활성 setTimeout id 목록 (stop 시 일괄 clearTimeout)
|
||||
currentStep: 0,
|
||||
startTime: 0,
|
||||
pausedAt: 0,
|
||||
waitingAck: false, // ACK 대기 중인지
|
||||
onAck: null, // ACK 클릭 시 호출할 콜백
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// SCENARIO DEFINITIONS
|
||||
// ============================================================
|
||||
// 1번 설비 = BW-A1 (원수 취수펌프 #1)
|
||||
// 시나리오: 토출압 과압 → 알람 → 자동 인터록 후보 → 담당자 호출 → 도착 → ACK
|
||||
const SCENARIO_BW_A1 = {
|
||||
id: 'bw-a1-overpressure',
|
||||
title: 'BW-A1 펌프 과압 / 누설 의심',
|
||||
targetComp: 'bw-a1', // P&ID에서 깜빡일 설비
|
||||
targetGauge: 'p-in-1', // 압력 알람 발생 게이지
|
||||
cam: { id: 'CAM-01', label: 'PUMP ROOM A / BW-A1' },
|
||||
officer: {
|
||||
name: '김영수',
|
||||
title: '안전관리책임자',
|
||||
phone: '010-7842-3019',
|
||||
role: 'EHS 1차 대응자',
|
||||
avatar: 'KY', // 이니셜 표기
|
||||
from: 'control-room', // 맵 시작 위치
|
||||
to: 'pump-room-a', // 맵 도착 위치
|
||||
},
|
||||
alarm: {
|
||||
code: 'P-IN-HH',
|
||||
severity: 'CRITICAL',
|
||||
title: 'P-IN 토출압 HH (≥7.0 bar) — 누설 의심',
|
||||
message: 'BW-A1 토출 압력이 정상 운전 범위(4.0~5.0 bar)를 초과했습니다.\n현장 점검이 즉시 필요합니다.',
|
||||
},
|
||||
steps: [
|
||||
{ at: 0, label: '시연 시작 + 시각 강조 (배너+핀+펌프)', action: ctx => beginScenario(ctx) },
|
||||
{ at: 400, label: '압력 1차 상승 (노란 영역)', action: ctx => pressureRise1(ctx) },
|
||||
{ at: 1100, label: '압력 2차 상승 (빨간 영역, HH 임계)', action: ctx => pressureRise2(ctx) },
|
||||
{ at: 1500, label: '★ CRITICAL 알람 발령', action: ctx => raiseAlarm(ctx) },
|
||||
{ at: 2000, label: '모달 팝업', action: ctx => openModal(ctx) },
|
||||
{ at: 2700, label: '핸드폰 알림 발송', action: ctx => triggerPhoneAlert(ctx) },
|
||||
{ at: 3700, label: '회사 맵 슬라이드 인', action: ctx => openMap(ctx) },
|
||||
{ at: 5200, label: '담당자 이동 시작', action: ctx => startOfficerMove(ctx) },
|
||||
{ at: 7700, label: '핸드폰 확인 탭', action: ctx => phoneTapAck(ctx) },
|
||||
{ at: 12200, label: '담당자 현장 도착', action: ctx => officerArrived(ctx) },
|
||||
{ at: 13200, label: 'ACK 대기 (시연자 클릭)', action: ctx => waitForOperatorAck(ctx) },
|
||||
],
|
||||
};
|
||||
|
||||
const SCENARIOS = { 'bw-a1-overpressure': SCENARIO_BW_A1 };
|
||||
|
||||
// ============================================================
|
||||
// STEP ACTIONS (각 step에서 호출)
|
||||
// ============================================================
|
||||
function beginScenario(ctx) {
|
||||
const E = global.INVYONE_ENGINE;
|
||||
E.emitEvent(`🚨 경고시스템 시연 시작 — ${ctx.scenario.title}`, 'alarm');
|
||||
if (E.state.currentMode !== 'NORMAL') E.applyMode('NORMAL');
|
||||
// 시연 시작 즉시 강력한 시각 효과 — 펌프 강조 + 알람 핀 + 상단 배너 동시
|
||||
highlightTarget(ctx.scenario.targetComp, true);
|
||||
flashTargetComp(ctx.scenario.targetComp);
|
||||
dispatch('scenario:bannerOn', ctx);
|
||||
dispatch('scenario:pinShow', ctx);
|
||||
}
|
||||
|
||||
function pressureRise1(ctx) {
|
||||
const E = global.INVYONE_ENGINE;
|
||||
const g = E.state.gauges[ctx.scenario.targetGauge];
|
||||
if (g) {
|
||||
g.forced = true;
|
||||
g.value = (g.max || 8) * 0.72; // 5.76 bar — 노란 주의 영역
|
||||
E.setGaugeVisual(ctx.scenario.targetGauge);
|
||||
}
|
||||
E.emitEvent(`P-IN 압력 상승 감지 (${g ? g.value.toFixed(1) : '?'} bar) — WARNING`, 'warn');
|
||||
}
|
||||
|
||||
function pressureRise2(ctx) {
|
||||
const E = global.INVYONE_ENGINE;
|
||||
const g = E.state.gauges[ctx.scenario.targetGauge];
|
||||
if (g) {
|
||||
g.forced = true;
|
||||
g.value = (g.max || 8) * 0.95; // 7.6 bar — 빨강 임계 초과
|
||||
E.setGaugeVisual(ctx.scenario.targetGauge);
|
||||
}
|
||||
E.emitEvent(`P-IN 압력 임계 초과 (${g ? g.value.toFixed(1) : '?'} bar) — HH 진입`, 'alarm');
|
||||
}
|
||||
|
||||
function raiseAlarm(ctx) {
|
||||
const E = global.INVYONE_ENGINE;
|
||||
E.emitEvent(`★ ${ctx.scenario.alarm.severity} — ${ctx.scenario.alarm.title}`, 'alarm');
|
||||
}
|
||||
|
||||
function openModal(ctx) { dispatch('scenario:openModal', ctx); }
|
||||
function triggerPhoneAlert(ctx) {
|
||||
dispatch('scenario:phoneAlert', ctx);
|
||||
pushAlarmToWorker(ctx);
|
||||
}
|
||||
function openMap(ctx) { dispatch('scenario:openMap', ctx); }
|
||||
function startOfficerMove(ctx) { dispatch('scenario:officerMove', ctx); }
|
||||
function phoneTapAck(ctx) { dispatch('scenario:phoneTap', ctx); }
|
||||
function officerArrived(ctx) {
|
||||
dispatch('scenario:officerArrived', ctx);
|
||||
global.INVYONE_ENGINE.emitEvent(`[현장도착] ${ctx.scenario.officer.name} ${ctx.scenario.officer.title} — 점검 시작`, 'info');
|
||||
}
|
||||
function waitForOperatorAck(ctx) {
|
||||
state.waitingAck = true;
|
||||
dispatch('scenario:waitAck', ctx);
|
||||
global.INVYONE_ENGINE.emitEvent('ACK 대기 중 — 시연자가 모달의 [확인] 버튼을 클릭하세요', 'warn');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PLAYER CONTROL
|
||||
// ============================================================
|
||||
function play(scenarioId) {
|
||||
const scenario = SCENARIOS[scenarioId];
|
||||
if (!scenario) { console.warn('Unknown scenario:', scenarioId); return; }
|
||||
if (state.running) stop();
|
||||
|
||||
state.running = true;
|
||||
state.paused = false;
|
||||
state.currentStep = 0;
|
||||
state.startTime = Date.now();
|
||||
state.waitingAck = false;
|
||||
|
||||
const ctx = { scenario, state };
|
||||
scenario.steps.forEach((step, i) => {
|
||||
const t = setTimeout(() => {
|
||||
if (!state.running) return;
|
||||
state.currentStep = i;
|
||||
try { step.action(ctx); } catch (err) { console.error('Scenario step error:', err); }
|
||||
}, step.at);
|
||||
state.timers.push(t);
|
||||
});
|
||||
|
||||
dispatch('scenario:started', ctx);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
state.timers.forEach(clearTimeout);
|
||||
state.timers = [];
|
||||
state.running = false;
|
||||
state.waitingAck = false;
|
||||
// 강제로 잡아둔 게이지 값 해제 (정상 자동 갱신 복귀)
|
||||
const E = global.INVYONE_ENGINE;
|
||||
if (E && E.state) {
|
||||
Object.values(SCENARIOS).forEach(s => {
|
||||
const g = E.state.gauges[s.targetGauge];
|
||||
if (g) g.forced = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 시연자가 ACK 클릭 → 정상 복귀
|
||||
function ackAndResolve() {
|
||||
if (!state.running) return;
|
||||
const E = global.INVYONE_ENGINE;
|
||||
const active = SCENARIO_BW_A1;
|
||||
|
||||
E.emitEvent('[조치완료] 운영자 ACK 수신 — 정상 복귀 절차 진행', 'info');
|
||||
const g = E.state.gauges[active.targetGauge];
|
||||
if (g) { g.forced = false; g.value = (g.max || 8) * 0.55; E.setGaugeVisual(active.targetGauge); }
|
||||
highlightTarget(active.targetComp, false);
|
||||
flashStop(active.targetComp);
|
||||
|
||||
dispatch('scenario:resolved', { scenario: active, state });
|
||||
stop();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 실제 작업자 폰 push (siflex.invyone.com/mobile 에 ws 로 연결된 사용자)
|
||||
// SCADA_ALARM_TARGET 미지정 시 no-op (로컬 데모 모드)
|
||||
// ============================================================
|
||||
function pushAlarmToWorker(ctx) {
|
||||
const target = (typeof window !== 'undefined' && window.SCADA_ALARM_TARGET) || '';
|
||||
if (!target) return;
|
||||
const a = ctx.scenario.alarm;
|
||||
const body = {
|
||||
target_user_id: target,
|
||||
alarm: {
|
||||
code: a.code,
|
||||
severity: a.severity,
|
||||
title: ctx.scenario.title,
|
||||
message: a.message,
|
||||
location: '펌프룸 A · BW-A1 (원수 취수펌프 #1)',
|
||||
comp: ctx.scenario.targetComp,
|
||||
},
|
||||
};
|
||||
fetch('/api/demo/alarm/trigger', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
const delivered = res && res.data && res.data.delivered_to;
|
||||
global.INVYONE_ENGINE.emitEvent(
|
||||
delivered > 0
|
||||
? `📱 작업자 폰 알람 전송 완료 (${target}) — ${delivered}건 수신`
|
||||
: `📱 작업자 폰 미접속 (${target}) — 알람 큐 없음`,
|
||||
delivered > 0 ? 'info' : 'warn'
|
||||
);
|
||||
})
|
||||
.catch(err => {
|
||||
global.INVYONE_ENGINE.emitEvent(`📱 폰 알람 전송 실패: ${err.message || err}`, 'warn');
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// VISUAL HELPERS (P&ID 안에서)
|
||||
// ============================================================
|
||||
function highlightTarget(compId, on) {
|
||||
const el = document.querySelector(`[data-comp-id="${compId}"]`);
|
||||
if (el) el.classList.toggle('scenario-target', on);
|
||||
}
|
||||
function flashTargetComp(compId) {
|
||||
const el = document.querySelector(`[data-comp-id="${compId}"]`);
|
||||
if (el) el.classList.add('scenario-flash');
|
||||
}
|
||||
function flashStop(compId) {
|
||||
const el = document.querySelector(`[data-comp-id="${compId}"]`);
|
||||
if (el) el.classList.remove('scenario-flash');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EVENT DISPATCH (scenario.js → ui.js 분리)
|
||||
// ============================================================
|
||||
function dispatch(name, detail) {
|
||||
document.dispatchEvent(new CustomEvent(name, { detail }));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PUBLIC API
|
||||
// ============================================================
|
||||
global.INVYONE_SCENARIO = {
|
||||
play, stop, ackAndResolve,
|
||||
SCENARIOS,
|
||||
state,
|
||||
};
|
||||
})(window);
|
||||
@@ -0,0 +1,610 @@
|
||||
// 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);
|
||||
@@ -0,0 +1,513 @@
|
||||
// INVYONE Stage-2 — UI / Interaction Layer
|
||||
// 클릭 위임, 사이드 패널 (선택된 컴포넌트 컨트롤), 모드 버튼, 클럭
|
||||
|
||||
(function (global) {
|
||||
let selectedId = null;
|
||||
let selectedType = null;
|
||||
|
||||
function init() {
|
||||
wireSceneClicks();
|
||||
wireSidebarButtons();
|
||||
wireGlobalControls();
|
||||
wireScenario();
|
||||
tickClock();
|
||||
setInterval(tickClock, 1000);
|
||||
setInterval(tickPhoneClock, 1000);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SCENE CLICK DELEGATION
|
||||
// ============================================================
|
||||
function wireSceneClicks() {
|
||||
const scene = document.getElementById('scene');
|
||||
if (!scene) return;
|
||||
|
||||
scene.addEventListener('click', (e) => {
|
||||
// cartridge takes precedence (deeper in DOM)
|
||||
const cart = e.target.closest('.cartridge');
|
||||
if (cart && cart.id.startsWith('cartridge-')) {
|
||||
const cid = cart.id.replace('cartridge-', '');
|
||||
global.INVYONE_ENGINE.isolateCartridge(cid);
|
||||
select(cid, 'cartridge');
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
const comp = e.target.closest('[data-comp-id]');
|
||||
if (!comp) { clearSelection(); return; }
|
||||
const id = comp.getAttribute('data-comp-id');
|
||||
const type = comp.getAttribute('data-comp-type');
|
||||
if (!id || !type) return;
|
||||
select(id, type);
|
||||
// single-click action
|
||||
if (type === 'pump') global.INVYONE_ENGINE.togglePump(id);
|
||||
else if (type === 'valve') global.INVYONE_ENGINE.toggleValve(id);
|
||||
});
|
||||
}
|
||||
|
||||
function select(id, type) {
|
||||
selectedId = id;
|
||||
selectedType = type;
|
||||
document.querySelectorAll('.comp.selected').forEach(el => el.classList.remove('selected'));
|
||||
const root = document.querySelector(`[data-comp-id="${id}"]${type ? `[data-comp-type="${type}"]` : ''}`);
|
||||
if (root) root.classList.add('selected');
|
||||
renderInspector();
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedId = null;
|
||||
selectedType = null;
|
||||
document.querySelectorAll('.comp.selected').forEach(el => el.classList.remove('selected'));
|
||||
renderInspector();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// INSPECTOR (선택된 컴포넌트 상세 패널)
|
||||
// ============================================================
|
||||
function renderInspector() {
|
||||
const box = document.getElementById('inspector');
|
||||
if (!box) return;
|
||||
if (!selectedId) {
|
||||
box.innerHTML = `
|
||||
<div class="inspector-empty">
|
||||
<div class="muted">컴포넌트를 클릭해 제어 패널을 여세요</div>
|
||||
<ul class="hint">
|
||||
<li>펌프 클릭 → ON/OFF</li>
|
||||
<li>밸브 클릭 → OPEN/CLOSE</li>
|
||||
<li>탱크 클릭 → 수위 슬라이더</li>
|
||||
<li>센서 클릭 → 값 직접 입력</li>
|
||||
<li>카트리지 클릭 → 격리(BYPASS)</li>
|
||||
</ul>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
const E = global.INVYONE_ENGINE;
|
||||
if (selectedType === 'tank') {
|
||||
const t = E.state.tanks[selectedId];
|
||||
const def = global.INVYONE_TOPO.TANKS.find(x => x.id === selectedId);
|
||||
box.innerHTML = `
|
||||
<h3>탱크 — ${def.label}</h3>
|
||||
<div class="row"><span>현재 수위</span><b id="insp-tank-pct">${t.level.toFixed(1)} %</b></div>
|
||||
<input type="range" min="0" max="100" step="0.1" value="${t.level}" id="insp-tank-slider" />
|
||||
<div class="row"><span>용량</span><b>${def.capacity.toLocaleString()} L</b></div>
|
||||
<div class="row"><span>잔량</span><b id="insp-tank-liters">${(def.capacity * t.level / 100).toFixed(0)} L</b></div>
|
||||
<div class="row"><span>알람 임계 (HH/HL/LH/LL)</span><b>${def.alarms.hh}/${def.alarms.hl}/${def.alarms.lh}/${def.alarms.ll}</b></div>
|
||||
<div class="row"><span>현재 알람</span><b class="alarm-tag ${t.alarmState || ''}">${(t.alarmState || 'NORMAL').toUpperCase()}</b></div>
|
||||
<div class="actions">
|
||||
<button data-act="fill-100">100%</button>
|
||||
<button data-act="fill-50">50%</button>
|
||||
<button data-act="empty">0%</button>
|
||||
</div>`;
|
||||
const sl = document.getElementById('insp-tank-slider');
|
||||
sl.addEventListener('input', e => {
|
||||
const v = parseFloat(e.target.value);
|
||||
E.setTankLevel(selectedId, v);
|
||||
document.getElementById('insp-tank-pct').textContent = v.toFixed(1) + ' %';
|
||||
document.getElementById('insp-tank-liters').textContent = (def.capacity * v / 100).toFixed(0) + ' L';
|
||||
});
|
||||
box.querySelectorAll('button[data-act]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const act = btn.getAttribute('data-act');
|
||||
if (act === 'fill-100') E.setTankLevel(selectedId, 100);
|
||||
if (act === 'fill-50') E.setTankLevel(selectedId, 50);
|
||||
if (act === 'empty') E.setTankLevel(selectedId, 0);
|
||||
renderInspector();
|
||||
});
|
||||
});
|
||||
} else if (selectedType === 'pump') {
|
||||
const p = E.state.pumps[selectedId];
|
||||
box.innerHTML = `
|
||||
<h3>펌프 — ${p.label || selectedId}</h3>
|
||||
<div class="row"><span>운전</span><b class="${p.running ? 'on' : 'off'}">${p.running ? 'ON' : 'OFF'}</b></div>
|
||||
<div class="row"><span>RPM</span><b id="insp-pump-rpm">${p.rpm}</b></div>
|
||||
<input type="range" min="0" max="3000" step="50" value="${p.rpm}" id="insp-pump-slider" />
|
||||
<div class="row"><span>용량</span><b>${p.lpm} L/min</b></div>
|
||||
<div class="row"><span>현재 유량</span><b id="insp-pump-flow">${p.running ? (p.lpm * (p.rpm / 1450)).toFixed(1) : '0.0'} L/min</b></div>
|
||||
<div class="row"><span>가동시간</span><b>${p.runtime.toFixed(0)} s</b></div>
|
||||
<div class="row"><span>흡입/토출</span><b>${p.source || '-'} → ${p.dest || '-'}</b></div>
|
||||
<div class="actions">
|
||||
<button data-act="toggle">${p.running ? 'STOP' : 'START'}</button>
|
||||
</div>`;
|
||||
const sl = document.getElementById('insp-pump-slider');
|
||||
sl.addEventListener('input', e => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
E.setRpm(selectedId, v);
|
||||
document.getElementById('insp-pump-rpm').textContent = v;
|
||||
document.getElementById('insp-pump-flow').textContent = (E.state.pumps[selectedId].running ? (p.lpm * (v / 1450)).toFixed(1) : '0.0') + ' L/min';
|
||||
});
|
||||
box.querySelector('[data-act="toggle"]').addEventListener('click', () => { E.togglePump(selectedId); renderInspector(); });
|
||||
} else if (selectedType === 'valve') {
|
||||
const v = E.state.valves[selectedId];
|
||||
box.innerHTML = `
|
||||
<h3>밸브 — ${v.label || selectedId}</h3>
|
||||
<div class="row"><span>상태</span><b class="${v.open ? 'on' : 'off'}">${v.open ? 'OPEN' : 'CLOSED'}</b></div>
|
||||
<div class="actions">
|
||||
<button data-act="toggle">${v.open ? 'CLOSE' : 'OPEN'}</button>
|
||||
</div>`;
|
||||
box.querySelector('[data-act="toggle"]').addEventListener('click', () => { E.toggleValve(selectedId); renderInspector(); });
|
||||
} else if (selectedType === 'sensor') {
|
||||
const s = E.state.sensors[selectedId];
|
||||
box.innerHTML = `
|
||||
<h3>센서 — ${s.label}</h3>
|
||||
<div class="row"><span>현재 값</span><b id="insp-sensor-val">${typeof s.value === 'number' ? s.value.toFixed(2) : s.value} ${s.unit}</b></div>
|
||||
<input type="range" min="${s.min}" max="${s.max}" step="0.01" value="${s.value}" id="insp-sensor-slider" />
|
||||
<div class="row"><span>측정범위</span><b>${s.min} ~ ${s.max} ${s.unit}</b></div>
|
||||
<div class="row"><span>임계 (LO / HI)</span><b>${s.alarms?.lo ?? '-'} / ${s.alarms?.hi ?? '-'}</b></div>
|
||||
<div class="row"><span>현재 알람</span><b class="alarm-tag ${s.alarmState || ''}">${(s.alarmState || 'NORMAL').toUpperCase()}</b></div>`;
|
||||
const sl = document.getElementById('insp-sensor-slider');
|
||||
sl.addEventListener('input', e => {
|
||||
const v = parseFloat(e.target.value);
|
||||
E.setSensor(selectedId, v);
|
||||
document.getElementById('insp-sensor-val').textContent = v.toFixed(2) + ' ' + s.unit;
|
||||
});
|
||||
} else if (selectedType === 'gauge') {
|
||||
const g = E.state.gauges[selectedId];
|
||||
box.innerHTML = `
|
||||
<h3>압력계 — ${g.label}</h3>
|
||||
<div class="row"><span>현재 값</span><b>${g.value.toFixed(2)} ${g.unit}</b></div>
|
||||
<div class="row"><span>최대</span><b>${g.max} ${g.unit}</b></div>
|
||||
<div class="row"><span>참조 펌프</span><b>${g.source || '-'}</b></div>
|
||||
<div class="muted small">압력은 참조 펌프의 운전 상태에 따라 자동 변동됩니다.</div>`;
|
||||
} else if (selectedType === 'cartridge') {
|
||||
const c = E.state.cartridges[selectedId] || { isolated: false };
|
||||
box.innerHTML = `
|
||||
<h3>카트리지 — ${selectedId}</h3>
|
||||
<div class="row"><span>상태</span><b class="${c.isolated ? 'off' : 'on'}">${c.isolated ? '격리(BYPASS)' : '정상'}</b></div>
|
||||
<div class="actions">
|
||||
<button data-act="toggle">${c.isolated ? '복귀' : '격리'}</button>
|
||||
</div>`;
|
||||
box.querySelector('[data-act="toggle"]').addEventListener('click', () => { E.isolateCartridge(selectedId); renderInspector(); });
|
||||
} else {
|
||||
box.innerHTML = `<h3>${selectedType.toUpperCase()} — ${selectedId}</h3><div class="muted">상세 컨트롤 없음</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SIDEBAR BUTTONS / GLOBAL CONTROLS
|
||||
// ============================================================
|
||||
function wireSidebarButtons() {
|
||||
const E = global.INVYONE_ENGINE;
|
||||
document.querySelectorAll('[data-mode]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const mode = btn.getAttribute('data-mode');
|
||||
E.applyMode(mode);
|
||||
document.querySelectorAll('[data-mode]').forEach(b => b.classList.toggle('active', b.getAttribute('data-mode') === mode));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function wireGlobalControls() {
|
||||
const E = global.INVYONE_ENGINE;
|
||||
bind('btn-reset', () => E.reset());
|
||||
bind('btn-pause', (btn) => {
|
||||
E.state.paused = !E.state.paused;
|
||||
btn.textContent = E.state.paused ? '▶ 재개' : '⏸ 일시정지';
|
||||
E.emitEvent(E.state.paused ? '시뮬레이션 일시정지' : '시뮬레이션 재개', 'info');
|
||||
});
|
||||
bind('btn-alarm', () => E.triggerDemoAlarm());
|
||||
bind('btn-clear-log', () => {
|
||||
const log = document.getElementById('event-log');
|
||||
if (log) log.innerHTML = '';
|
||||
E.state.events.length = 0;
|
||||
});
|
||||
bind('btn-deselect', () => clearSelection());
|
||||
const flowMul = document.getElementById('flow-multiplier');
|
||||
if (flowMul) flowMul.addEventListener('input', e => {
|
||||
const v = parseFloat(e.target.value);
|
||||
E.state.flowMultiplier = v;
|
||||
const out = document.getElementById('flow-multiplier-val');
|
||||
if (out) out.textContent = v.toFixed(1) + 'x';
|
||||
});
|
||||
}
|
||||
|
||||
function bind(id, fn) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener('click', () => fn(el));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CLOCK
|
||||
// ============================================================
|
||||
function tickClock() {
|
||||
const el = document.getElementById('clock');
|
||||
if (!el) return;
|
||||
const d = new Date();
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
el.textContent = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())} ${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
function tickPhoneClock() {
|
||||
const el = document.getElementById('phone-time');
|
||||
if (!el) return;
|
||||
const d = new Date();
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
el.textContent = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EMERGENCY SCENARIO — 시연 시퀀스 핸들러
|
||||
// ============================================================
|
||||
function wireScenario() {
|
||||
bind('btn-emergency', () => {
|
||||
// 활성 시나리오면 중단, 아니면 새로 시작
|
||||
if (global.INVYONE_SCENARIO.state.running) {
|
||||
closeScenario();
|
||||
global.INVYONE_SCENARIO.stop();
|
||||
global.INVYONE_ENGINE.emitEvent('시연 중단', 'warn');
|
||||
} else {
|
||||
global.INVYONE_SCENARIO.play('bw-a1-overpressure');
|
||||
}
|
||||
});
|
||||
|
||||
// 모달 푸터
|
||||
bind('modal-skip', () => { closeScenario(); global.INVYONE_SCENARIO.stop(); global.INVYONE_ENGINE.emitEvent('시연 스킵', 'info'); });
|
||||
bind('modal-close', () => { closeScenario(); global.INVYONE_SCENARIO.stop(); });
|
||||
bind('modal-ack', () => { global.INVYONE_SCENARIO.ackAndResolve(); });
|
||||
bind('modal-dispatch', () => {
|
||||
// "즉시 출동 요청" — 핸드폰에 알림 한 번 더 (시연자 효과)
|
||||
const phone = document.getElementById('phone-notify');
|
||||
if (phone) { phone.classList.add('shake'); setTimeout(() => phone.classList.remove('shake'), 3500); }
|
||||
global.INVYONE_ENGINE.emitEvent('[추가발송] 출동 요청 재전송', 'warn');
|
||||
});
|
||||
bind('phone-confirm', () => {
|
||||
const phone = document.getElementById('phone-notify');
|
||||
if (phone) phone.classList.remove('show');
|
||||
global.INVYONE_ENGINE.emitEvent('[수신확인] 김영수 책임자 알림 확인 완료', 'info');
|
||||
});
|
||||
|
||||
// 시나리오 이벤트 → UI 반영
|
||||
document.addEventListener('scenario:bannerOn', e => {
|
||||
const s = e.detail.scenario;
|
||||
const banner = qid('alarm-banner');
|
||||
if (!banner) return;
|
||||
qid('alarm-banner-code').textContent = s.alarm.code;
|
||||
qid('alarm-banner-msg').textContent = `${s.targetComp.toUpperCase()} 토출 압력 — 정상범위(4.0~5.0 bar) 초과 / ${s.alarm.title.split('—').pop().trim() || '누설 의심'}`;
|
||||
qid('alarm-banner-loc-text').textContent = `펌프룸 A · ${s.targetComp.toUpperCase()} (원수 취수펌프 #1)`;
|
||||
banner.classList.add('show');
|
||||
});
|
||||
|
||||
document.addEventListener('scenario:pinShow', e => {
|
||||
showPidAlarmPin(e.detail.scenario.targetComp);
|
||||
});
|
||||
|
||||
document.addEventListener('scenario:openModal', e => {
|
||||
const s = e.detail.scenario;
|
||||
qid('modal-alarm-code').textContent = s.alarm.code;
|
||||
qid('modal-alarm-title').textContent = s.title;
|
||||
qid('modal-alarm-message').textContent = s.alarm.message;
|
||||
qid('modal-cctv-cam').textContent = `${s.cam.id} / ${s.cam.label}`;
|
||||
// 사진 이미지 사용 — textContent 덮어쓰면 <img> 가 사라지므로 비활성화
|
||||
// qid('modal-officer-avatar').textContent = s.officer.avatar;
|
||||
qid('modal-officer-name').textContent = s.officer.name;
|
||||
qid('modal-officer-title').textContent = s.officer.title;
|
||||
qid('modal-officer-role').textContent = s.officer.role;
|
||||
qid('modal-officer-phone').textContent = s.officer.phone;
|
||||
qid('modal-officer-loc').textContent = '관제실';
|
||||
const stat = qid('modal-officer-status');
|
||||
stat.textContent = '📡 알림 발송 중...';
|
||||
stat.className = 'officer-status';
|
||||
qid('modal-ack').disabled = true;
|
||||
qid('modal-ack').textContent = '✓ 확인 (ACK) — 담당자 도착 후 활성화';
|
||||
|
||||
const modal = qid('emergency-modal');
|
||||
if (modal) modal.classList.add('show');
|
||||
// 모달 떠있는 동안 메인 P&ID 엔진 일시정지 — 250ms tick / 펌프회전 / 게이지 갱신 부하 제거
|
||||
try { global.INVYONE_ENGINE.state.paused = true; } catch {}
|
||||
// CCTV time 업데이트 시작
|
||||
startCctvClock();
|
||||
// CCTV 영상 초기화 — 출동 중(going) 영상 fade-in, 도착(arrived) 영상은 hidden 으로 미리 buffer
|
||||
const going = document.querySelector('.cctv-video-going');
|
||||
const arrived = document.querySelector('.cctv-video-arrived');
|
||||
if (going) {
|
||||
// 첫 재생은 풀(0~8초), 그 이후 루프는 5초부터 (모터 정지 구간 회피)
|
||||
going.currentTime = 0;
|
||||
if (!going.dataset.loopHandler) {
|
||||
going.addEventListener('ended', () => {
|
||||
going.currentTime = 7;
|
||||
going.play().catch(() => {});
|
||||
});
|
||||
going.dataset.loopHandler = '1';
|
||||
}
|
||||
going.classList.add('active');
|
||||
going.play().catch(() => {});
|
||||
}
|
||||
if (arrived) {
|
||||
arrived.classList.remove('active');
|
||||
arrived.pause();
|
||||
arrived.currentTime = 0;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('scenario:phoneAlert', e => {
|
||||
const s = e.detail.scenario;
|
||||
qid('phone-msg').textContent = `${s.alarm.title}\n현장으로 즉시 이동 바랍니다.`;
|
||||
const phone = qid('phone-notify');
|
||||
if (phone) {
|
||||
phone.classList.add('show');
|
||||
phone.classList.add('shake');
|
||||
setTimeout(() => phone.classList.remove('shake'), 3500);
|
||||
}
|
||||
const stat = qid('modal-officer-status');
|
||||
if (stat) { stat.textContent = '📲 휴대폰 알림 도달'; stat.className = 'officer-status delivered'; }
|
||||
global.INVYONE_ENGINE.emitEvent(`[자동발송] ${s.officer.name} ${s.officer.title} 휴대폰 알림 전송 (${s.officer.phone})`, 'info');
|
||||
});
|
||||
|
||||
document.addEventListener('scenario:openMap', e => {
|
||||
const map = qid('map-panel');
|
||||
if (map) map.classList.add('open');
|
||||
// 모달 backdrop과 상단 배너 모두 좌측만 차지하도록 좁힘 (맵이 보이게)
|
||||
const modal = qid('emergency-modal');
|
||||
if (modal) modal.classList.add('with-map');
|
||||
const banner = qid('alarm-banner');
|
||||
if (banner) banner.classList.add('with-map');
|
||||
const pin = qid('map-alarm-pin');
|
||||
const route = qid('map-route');
|
||||
if (pin) pin.classList.add('active');
|
||||
if (route) route.classList.add('active');
|
||||
const target = document.querySelector(`.map-building[data-bid="pump-room-a"]`);
|
||||
if (target) target.classList.add('alarm-target');
|
||||
});
|
||||
|
||||
document.addEventListener('scenario:officerMove', e => {
|
||||
const off = qid('map-officer');
|
||||
if (off) {
|
||||
off.classList.add('moving');
|
||||
// 관제실(160,140) → 펌프룸 A(180,345)
|
||||
off.setAttribute('transform', 'translate(110 81)');
|
||||
}
|
||||
const stat = qid('modal-officer-status');
|
||||
if (stat) { stat.textContent = '🏃 현장 이동 중'; stat.className = 'officer-status responding'; }
|
||||
qid('modal-officer-loc').textContent = '이동 중...';
|
||||
qid('map-officer-status').textContent = '김영수 책임자 — 펌프룸 A 이동 중';
|
||||
global.INVYONE_ENGINE.emitEvent(`[이동개시] 관제실 → 펌프룸 A`, 'info');
|
||||
});
|
||||
|
||||
document.addEventListener('scenario:phoneTap', e => {
|
||||
const btn = qid('phone-confirm');
|
||||
if (btn) {
|
||||
btn.classList.add('highlight');
|
||||
setTimeout(() => {
|
||||
btn.classList.add('tapped');
|
||||
setTimeout(() => {
|
||||
const phone = qid('phone-notify');
|
||||
if (phone) phone.classList.remove('show');
|
||||
btn.classList.remove('tapped', 'highlight');
|
||||
}, 600);
|
||||
}, 1500);
|
||||
}
|
||||
global.INVYONE_ENGINE.emitEvent(`[수신확인] 김영수 책임자 알림 확인 완료`, 'info');
|
||||
});
|
||||
|
||||
document.addEventListener('scenario:officerArrived', e => {
|
||||
const off = qid('map-officer');
|
||||
if (off) off.classList.remove('moving');
|
||||
const stat = qid('modal-officer-status');
|
||||
if (stat) { stat.textContent = '✅ 현장 도착 — 점검 시작'; stat.className = 'officer-status arrived'; }
|
||||
qid('modal-officer-loc').textContent = '펌프룸 A';
|
||||
qid('map-officer-status').textContent = '김영수 책임자 — 펌프룸 A 도착, 점검 중';
|
||||
// CCTV 영상 전환 (fade swap, 깜빡임 없음): 출동 중 → 도착 영상
|
||||
const going = document.querySelector('.cctv-video-going');
|
||||
const arrived = document.querySelector('.cctv-video-arrived');
|
||||
if (arrived) {
|
||||
arrived.currentTime = 0;
|
||||
arrived.loop = false;
|
||||
arrived.classList.add('active');
|
||||
arrived.play().catch(() => {});
|
||||
}
|
||||
if (going) {
|
||||
// fade-out 끝난 후 정지 (transition 0.45s 와 매칭)
|
||||
setTimeout(() => { going.pause(); going.classList.remove('active'); }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('scenario:waitAck', e => {
|
||||
const ack = qid('modal-ack');
|
||||
if (ack) {
|
||||
ack.disabled = false;
|
||||
ack.textContent = '✓ 확인 (ACK) — 알람 해제';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('scenario:resolved', e => {
|
||||
// 천천히 닫기 (시연자가 흐름 인지하도록)
|
||||
setTimeout(() => closeScenario(), 1500);
|
||||
});
|
||||
}
|
||||
|
||||
function closeScenario() {
|
||||
const modal = qid('emergency-modal');
|
||||
if (modal) { modal.classList.remove('show'); modal.classList.remove('with-map'); }
|
||||
const map = qid('map-panel'); if (map) map.classList.remove('open');
|
||||
const phone = qid('phone-notify'); if (phone) phone.classList.remove('show');
|
||||
const banner = qid('alarm-banner');
|
||||
if (banner) { banner.classList.remove('show'); banner.classList.remove('with-map'); }
|
||||
hidePidAlarmPin('bw-a1');
|
||||
// 맵 상태 리셋
|
||||
const off = qid('map-officer');
|
||||
if (off) { off.classList.remove('moving'); off.setAttribute('transform', 'translate(857 110)'); }
|
||||
// CCTV 영상 정지 — 모달 닫힐 때 두 video 모두 백그라운드 재생 방지
|
||||
document.querySelectorAll('.cctv-video').forEach(v => {
|
||||
v.pause();
|
||||
v.classList.remove('active');
|
||||
});
|
||||
// 메인 P&ID 엔진 재개
|
||||
try { global.INVYONE_ENGINE.state.paused = false; } catch {}
|
||||
const pin = qid('map-alarm-pin'); if (pin) pin.classList.remove('active');
|
||||
const route = qid('map-route'); if (route) route.classList.remove('active');
|
||||
const target = document.querySelector(`.map-building[data-bid="pump-room-a"]`);
|
||||
if (target) target.classList.remove('alarm-target');
|
||||
qid('map-officer-status').textContent = '김영수 책임자 — 관제실 대기';
|
||||
stopCctvClock();
|
||||
}
|
||||
|
||||
// P&ID 안에 BW-A1 위로 알람 핀 SVG 그룹 동적 부착/제거
|
||||
function showPidAlarmPin(compId) {
|
||||
const ports = global.INVYONE_TOPO.computePorts();
|
||||
const topPort = ports[`${compId}.top`] || ports[`${compId}.center`];
|
||||
if (!topPort) return;
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
const sceneContent = document.getElementById('scene-content');
|
||||
if (!sceneContent) return;
|
||||
let g = document.getElementById('scenario-pin-' + compId);
|
||||
if (!g) {
|
||||
g = document.createElementNS(ns, 'g');
|
||||
g.setAttribute('id', 'scenario-pin-' + compId);
|
||||
g.setAttribute('class', 'scenario-alarm-pin');
|
||||
sceneContent.appendChild(g);
|
||||
}
|
||||
g.setAttribute('transform', `translate(${topPort.x} ${topPort.y - 56})`);
|
||||
g.innerHTML = `
|
||||
<circle class="pin-ring-1" r="22" fill="#ff4f9a" opacity="0.55"/>
|
||||
<circle class="pin-ring-2" r="22" fill="#ff4f9a" opacity="0.55"/>
|
||||
<circle class="pin-core" r="18" fill="#ff4f9a"/>
|
||||
<circle r="18" fill="none" stroke="#fff" stroke-width="2"/>
|
||||
<text class="pin-core" dy="6" text-anchor="middle" font-size="20" font-weight="700" fill="#fff" pointer-events="none">⚠</text>
|
||||
<rect x="-46" y="-44" width="92" height="16" fill="#ff4f9a" stroke="#fff" stroke-width="0.8" pointer-events="none"/>
|
||||
<text y="-32" text-anchor="middle" font-size="10" font-weight="800" fill="#fff" pointer-events="none">ALARM HERE</text>
|
||||
`;
|
||||
}
|
||||
function hidePidAlarmPin(compId) {
|
||||
const g = document.getElementById('scenario-pin-' + compId);
|
||||
if (g) g.remove();
|
||||
}
|
||||
|
||||
let cctvClockTimer = null;
|
||||
function startCctvClock() {
|
||||
stopCctvClock();
|
||||
const upd = () => {
|
||||
const el = qid('modal-cctv-time');
|
||||
if (!el) return;
|
||||
const d = new Date();
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
el.textContent = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
};
|
||||
upd();
|
||||
cctvClockTimer = setInterval(upd, 1000);
|
||||
}
|
||||
function stopCctvClock() {
|
||||
if (cctvClockTimer) { clearInterval(cctvClockTimer); cctvClockTimer = null; }
|
||||
}
|
||||
|
||||
function qid(id) { return document.getElementById(id); }
|
||||
|
||||
global.INVYONE_UI = { init, select, clearSelection };
|
||||
})(window);
|
||||
@@ -0,0 +1,104 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 110" overflow="visible" role="img" aria-label="INVYONE carbon cartridge bank" style="--invyone-stroke:#aaaaaa;--invyone-stroke-strong:#ffffff;--invyone-flow:#5af9ff;--invyone-active:#7cff3a;--invyone-warning:#ff8a3a;--invyone-alarm:#ff4f9a;--invyone-idle:#2a3f5a;--module-fluid:#5af9ff;--module-shell:#081326;--module-accent:#7cff3a;">
|
||||
<defs>
|
||||
<linearGradient id="bank-metal-h" gradientUnits="userSpaceOnUse" x1="0" y1="22" x2="0" y2="34">
|
||||
<stop offset="0" stop-color="#d9d9d9"/>
|
||||
<stop offset="0.18" stop-color="#f5f5f5"/>
|
||||
<stop offset="0.5" stop-color="#8f8f8f"/>
|
||||
<stop offset="0.82" stop-color="#d7d7d7"/>
|
||||
<stop offset="1" stop-color="#6f6f6f"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bank-metal-v" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="14" y2="0">
|
||||
<stop offset="0" stop-color="#d9d9d9"/>
|
||||
<stop offset="0.18" stop-color="#f5f5f5"/>
|
||||
<stop offset="0.5" stop-color="#8f8f8f"/>
|
||||
<stop offset="0.82" stop-color="#d7d7d7"/>
|
||||
<stop offset="1" stop-color="#6f6f6f"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<style>
|
||||
.stroke{stroke:var(--invyone-stroke);stroke-width:1;vector-effect:non-scaling-stroke}
|
||||
.strong{stroke:var(--invyone-stroke-strong);stroke-width:1;vector-effect:non-scaling-stroke}
|
||||
.txt{fill:#ffffff;font-family:Arial,Helvetica,sans-serif}
|
||||
.txt2{fill:#cfd3d8;font-family:Arial,Helvetica,sans-serif}
|
||||
.flow{fill:none;stroke:var(--invyone-flow);stroke-width:2;stroke-linecap:round;stroke-dasharray:4 4;vector-effect:non-scaling-stroke}
|
||||
</style>
|
||||
|
||||
<text x="80" y="10" class="txt" font-size="8" text-anchor="middle">CARBON CARTRIDGE BANK</text>
|
||||
|
||||
<rect id="module-inlet" x="2" y="22" width="30" height="12" rx="2" fill="url(#bank-metal-h)" class="stroke"/>
|
||||
<rect x="6" y="25" width="22" height="6" rx="1.5" fill="var(--invyone-idle)" class="stroke"/>
|
||||
<line x1="8" y1="23" x2="8" y2="33" class="strong"/>
|
||||
<line x1="12" y1="23" x2="12" y2="33" class="stroke"/>
|
||||
|
||||
<rect x="32" y="22" width="96" height="12" rx="2" fill="url(#bank-metal-h)" class="stroke"/>
|
||||
<rect x="36" y="25" width="88" height="6" rx="1.5" fill="var(--invyone-idle)" class="stroke"/>
|
||||
<line x1="36" y1="23" x2="36" y2="33" class="strong"/>
|
||||
<line x1="40" y1="23" x2="40" y2="33" class="stroke"/>
|
||||
<line x1="120" y1="23" x2="120" y2="33" class="stroke"/>
|
||||
<line x1="124" y1="23" x2="124" y2="33" class="strong"/>
|
||||
|
||||
<rect id="module-outlet" x="128" y="22" width="30" height="12" rx="2" fill="url(#bank-metal-h)" class="stroke"/>
|
||||
<rect x="132" y="25" width="22" height="6" rx="1.5" fill="var(--invyone-idle)" class="stroke"/>
|
||||
<line x1="148" y1="23" x2="148" y2="33" class="stroke"/>
|
||||
<line x1="152" y1="23" x2="152" y2="33" class="strong"/>
|
||||
|
||||
<g id="module-housing">
|
||||
<g id="cartridge-c1" class="cartridge" transform="translate(40 34)">
|
||||
<rect class="cartridge-shell" x="0" y="0" width="14" height="52" rx="6" fill="url(#bank-metal-v)" stroke="var(--invyone-stroke)" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect class="cartridge-fluid" x="3" y="8" width="8" height="34" rx="3" fill="var(--module-fluid)" opacity="0.26" stroke="var(--invyone-stroke)" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect x="1" y="4" width="12" height="4" rx="1" fill="#11203a" class="strong"/>
|
||||
<rect x="1" y="42" width="12" height="4" rx="1" fill="#11203a" class="strong"/>
|
||||
<rect x="4" y="51" width="6" height="6" rx="1" fill="#11203a" class="strong"/>
|
||||
</g>
|
||||
<g id="cartridge-c2" class="cartridge" transform="translate(58 34)">
|
||||
<rect class="cartridge-shell" x="0" y="0" width="14" height="52" rx="6" fill="url(#bank-metal-v)" stroke="var(--invyone-stroke)" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect class="cartridge-fluid" x="3" y="8" width="8" height="34" rx="3" fill="var(--module-fluid)" opacity="0.26" stroke="var(--invyone-stroke)" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect x="1" y="4" width="12" height="4" rx="1" fill="#11203a" class="strong"/>
|
||||
<rect x="1" y="42" width="12" height="4" rx="1" fill="#11203a" class="strong"/>
|
||||
<rect x="4" y="51" width="6" height="6" rx="1" fill="#11203a" class="strong"/>
|
||||
</g>
|
||||
<g id="cartridge-c3" class="cartridge" transform="translate(76 34)">
|
||||
<rect class="cartridge-shell" x="0" y="0" width="14" height="52" rx="6" fill="url(#bank-metal-v)" stroke="var(--invyone-stroke)" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect class="cartridge-fluid" x="3" y="8" width="8" height="34" rx="3" fill="var(--module-fluid)" opacity="0.26" stroke="var(--invyone-stroke)" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect x="1" y="4" width="12" height="4" rx="1" fill="#11203a" class="strong"/>
|
||||
<rect x="1" y="42" width="12" height="4" rx="1" fill="#11203a" class="strong"/>
|
||||
<rect x="4" y="51" width="6" height="6" rx="1" fill="#11203a" class="strong"/>
|
||||
</g>
|
||||
<g id="cartridge-c4" class="cartridge" transform="translate(94 34)">
|
||||
<rect class="cartridge-shell" x="0" y="0" width="14" height="52" rx="6" fill="url(#bank-metal-v)" stroke="var(--invyone-stroke)" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect class="cartridge-fluid" x="3" y="8" width="8" height="34" rx="3" fill="var(--module-fluid)" opacity="0.26" stroke="var(--invyone-stroke)" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect x="1" y="4" width="12" height="4" rx="1" fill="#11203a" class="strong"/>
|
||||
<rect x="1" y="42" width="12" height="4" rx="1" fill="#11203a" class="strong"/>
|
||||
<rect x="4" y="51" width="6" height="6" rx="1" fill="#11203a" class="strong"/>
|
||||
</g>
|
||||
<g id="cartridge-c5" class="cartridge" transform="translate(112 34)">
|
||||
<rect class="cartridge-shell" x="0" y="0" width="14" height="52" rx="6" fill="url(#bank-metal-v)" stroke="var(--invyone-stroke)" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect class="cartridge-fluid" x="3" y="8" width="8" height="34" rx="3" fill="var(--module-fluid)" opacity="0.26" stroke="var(--invyone-stroke)" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<rect x="1" y="4" width="12" height="4" rx="1" fill="#11203a" class="strong"/>
|
||||
<rect x="1" y="42" width="12" height="4" rx="1" fill="#11203a" class="strong"/>
|
||||
<rect x="4" y="51" width="6" height="6" rx="1" fill="#11203a" class="strong"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g id="module-fluid">
|
||||
<line x1="16" y1="28" x2="144" y2="28" class="flow"/>
|
||||
<line x1="47" y1="28" x2="47" y2="78" class="flow"/>
|
||||
<line x1="65" y1="28" x2="65" y2="78" class="flow"/>
|
||||
<line x1="83" y1="28" x2="83" y2="78" class="flow"/>
|
||||
<line x1="101" y1="28" x2="101" y2="78" class="flow"/>
|
||||
<line x1="119" y1="28" x2="119" y2="78" class="flow"/>
|
||||
</g>
|
||||
|
||||
<g class="txt2" font-size="6" text-anchor="middle">
|
||||
<text x="47" y="95">C1</text>
|
||||
<text x="65" y="95">C2</text>
|
||||
<text x="83" y="95">C3</text>
|
||||
<text x="101" y="95">C4</text>
|
||||
<text x="119" y="95">C5</text>
|
||||
</g>
|
||||
|
||||
<g class="txt2" font-size="6">
|
||||
<text x="6" y="20">IN</text>
|
||||
<text x="144" y="20">OUT</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
@@ -0,0 +1,70 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" overflow="visible" role="img" aria-label="INVYONE analog pressure gauge" style="--invyone-stroke:#aaaaaa;--invyone-stroke-strong:#ffffff;--invyone-flow:#5af9ff;--invyone-active:#7cff3a;--invyone-warning:#ff8a3a;--invyone-alarm:#ff4f9a;--invyone-idle:#2a3f5a;--invyone-panel:#081326;--invyone-text:#ffffff;--invyone-text-secondary:#cfd3d8;--gauge-needle:#ff8a3a;--gauge-accent:#5af9ff;--gauge-unit:#cfd3d8;">
|
||||
<defs>
|
||||
<linearGradient id="gauge-metal-grad" gradientUnits="userSpaceOnUse" x1="0" y1="12" x2="0" y2="68">
|
||||
<stop offset="0" stop-color="#d9d9d9"/>
|
||||
<stop offset="0.18" stop-color="#f5f5f5"/>
|
||||
<stop offset="0.5" stop-color="#8f8f8f"/>
|
||||
<stop offset="0.82" stop-color="#d7d7d7"/>
|
||||
<stop offset="1" stop-color="#6f6f6f"/>
|
||||
</linearGradient>
|
||||
<path id="tick-major" d="M40 13 L40 18"/>
|
||||
<path id="tick-minor" d="M40 14.5 L40 17.5"/>
|
||||
<path id="dial-arc" d="M18 51 A24 24 0 0 1 62 51"/>
|
||||
</defs>
|
||||
<style>
|
||||
.stroke{stroke:var(--invyone-stroke);stroke-width:1;vector-effect:non-scaling-stroke}
|
||||
.strong{stroke:var(--invyone-stroke-strong);stroke-width:1;vector-effect:non-scaling-stroke}
|
||||
.panel{fill:var(--invyone-panel)}
|
||||
.t-primary{fill:var(--invyone-text);font-family:Arial,Helvetica,sans-serif}
|
||||
.t-secondary{fill:var(--invyone-text-secondary);font-family:Arial,Helvetica,sans-serif}
|
||||
.t-mono{fill:var(--invyone-text);font-family:Consolas,'Courier New',monospace}
|
||||
</style>
|
||||
|
||||
<circle cx="40" cy="40" r="29" fill="url(#gauge-metal-grad)" class="stroke"/>
|
||||
<circle cx="40" cy="40" r="26" fill="#0b1528" class="strong"/>
|
||||
<circle id="gauge-bg" cx="40" cy="40" r="22" class="panel strong"/>
|
||||
|
||||
<path d="M20.95 50 A21 21 0 0 1 28.5 23.1" fill="none" stroke="var(--invyone-active)" stroke-width="2" vector-effect="non-scaling-stroke"/>
|
||||
<path d="M28.5 23.1 A21 21 0 0 1 51.5 23.1" fill="none" stroke="var(--invyone-warning)" stroke-width="2" vector-effect="non-scaling-stroke"/>
|
||||
<path d="M51.5 23.1 A21 21 0 0 1 59.05 50" fill="none" stroke="var(--invyone-alarm)" stroke-width="2" vector-effect="non-scaling-stroke"/>
|
||||
|
||||
<g stroke="var(--invyone-stroke-strong)" fill="none" vector-effect="non-scaling-stroke">
|
||||
<use href="#tick-major" transform="rotate(-120 40 40)"/>
|
||||
<use href="#tick-minor" transform="rotate(-100 40 40)"/>
|
||||
<use href="#tick-minor" transform="rotate(-80 40 40)"/>
|
||||
<use href="#tick-major" transform="rotate(-60 40 40)"/>
|
||||
<use href="#tick-minor" transform="rotate(-40 40 40)"/>
|
||||
<use href="#tick-minor" transform="rotate(-20 40 40)"/>
|
||||
<use href="#tick-major" transform="rotate(0 40 40)"/>
|
||||
<use href="#tick-minor" transform="rotate(20 40 40)"/>
|
||||
<use href="#tick-minor" transform="rotate(40 40 40)"/>
|
||||
<use href="#tick-major" transform="rotate(60 40 40)"/>
|
||||
<use href="#tick-minor" transform="rotate(80 40 40)"/>
|
||||
<use href="#tick-minor" transform="rotate(100 40 40)"/>
|
||||
<use href="#tick-major" transform="rotate(120 40 40)"/>
|
||||
</g>
|
||||
|
||||
<g class="t-secondary" font-size="6" text-anchor="middle">
|
||||
<text x="19.5" y="54">0</text>
|
||||
<text x="23.5" y="31.5">2</text>
|
||||
<text x="40" y="20">4</text>
|
||||
<text x="56.5" y="31.5">6</text>
|
||||
<text x="60.5" y="54">8</text>
|
||||
</g>
|
||||
|
||||
<text x="40" y="32" class="t-primary" font-size="7" text-anchor="middle">PRESS</text>
|
||||
<text x="40" y="57" class="t-secondary" font-size="6" text-anchor="middle" id="gauge-unit">bar</text>
|
||||
|
||||
<g id="gauge-fill">
|
||||
<path d="M40 40 L40 21" fill="none" stroke="var(--gauge-accent)" stroke-width="0.7" opacity="0.35" vector-effect="non-scaling-stroke"/>
|
||||
</g>
|
||||
<g id="gauge-needle" transform="rotate(0 40 40)">
|
||||
<path d="M40 40 L40 21" fill="none" stroke="var(--gauge-needle)" stroke-width="2" stroke-linecap="round" vector-effect="non-scaling-stroke"/>
|
||||
<path d="M38.5 41.5 L40 19.5 L41.5 41.5 Z" fill="var(--gauge-needle)" opacity="0.18"/>
|
||||
</g>
|
||||
<circle cx="40" cy="40" r="4.6" fill="#111a2b" class="strong"/>
|
||||
<circle cx="40" cy="40" r="2.3" fill="url(#gauge-metal-grad)" class="stroke"/>
|
||||
|
||||
<rect x="24" y="61" width="32" height="10" rx="2" fill="#000000" class="strong"/>
|
||||
<text id="gauge-text" x="40" y="68" class="t-mono" font-size="11" text-anchor="middle">0.0</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,167 @@
|
||||
/* INVYONE Mobile Alarm — 작업자 동반 화면 (siflex.invyone.com/mobile)
|
||||
* v5 토큰만 사용. 메인 화면 이후 영역이라 backdrop-filter 금지 — solid + glow.
|
||||
* 알람 모드는 위험 분위기 (red/orange) 로 전환. */
|
||||
|
||||
.m-shell{
|
||||
position:fixed; inset:0;
|
||||
display:flex; flex-direction:column;
|
||||
background:var(--v5-surface-solid);
|
||||
color:var(--v5-text);
|
||||
overflow:hidden;
|
||||
font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Apple SD Gothic Neo","Malgun Gothic",sans-serif;
|
||||
-webkit-tap-highlight-color:transparent;
|
||||
user-select:none;
|
||||
touch-action:manipulation;
|
||||
}
|
||||
|
||||
/* ===== 헤더 (브랜드 + 연결 상태 + 사용자) ===== */
|
||||
.m-head{
|
||||
flex:0 0 auto;
|
||||
display:flex; align-items:center; justify-content:space-between; gap:10px;
|
||||
padding:14px 18px;
|
||||
border-bottom:1px solid var(--v5-border);
|
||||
font-size:13px;
|
||||
}
|
||||
.m-brand{
|
||||
font-weight:700; letter-spacing:0.02em;
|
||||
background:linear-gradient(135deg,var(--v5-primary),var(--v5-cyan));
|
||||
-webkit-background-clip:text; background-clip:text; color:transparent;
|
||||
}
|
||||
.m-conn{display:inline-flex; align-items:center; gap:6px; font-size:11px; color:#888;}
|
||||
.m-conn.ok{color:#4ade80}
|
||||
.m-conn.off{color:#f87171}
|
||||
.m-dot{width:8px; height:8px; border-radius:50%; background:currentColor; box-shadow:0 0 8px currentColor;}
|
||||
.m-conn.ok .m-dot{animation:m-pulse 1.5s ease-in-out infinite}
|
||||
@keyframes m-pulse{0%,100%{opacity:1} 50%{opacity:0.45}}
|
||||
|
||||
/* ===== IDLE — 평상시 ===== */
|
||||
.m-idle{
|
||||
flex:1 1 auto;
|
||||
display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||
gap:14px; position:relative;
|
||||
}
|
||||
.m-clock{
|
||||
font-size:clamp(56px,16vw,110px);
|
||||
font-weight:200; letter-spacing:0.04em;
|
||||
font-variant-numeric:tabular-nums;
|
||||
background:linear-gradient(135deg,var(--v5-primary),var(--v5-cyan));
|
||||
-webkit-background-clip:text; background-clip:text; color:transparent;
|
||||
text-shadow:0 0 30px rgba(var(--v5-primary-rgb),0.18);
|
||||
z-index:1;
|
||||
}
|
||||
.m-user{font-size:16px; color:var(--v5-text); z-index:1}
|
||||
.m-user-dept{font-size:12px; color:#888; margin-top:-8px; z-index:1}
|
||||
.m-idle-hint{
|
||||
font-size:12px; color:#888;
|
||||
margin-top:24px; padding:0 24px; text-align:center; line-height:1.6;
|
||||
z-index:1;
|
||||
}
|
||||
.m-glow-orb{
|
||||
position:absolute;
|
||||
width:340px; height:340px; border-radius:50%;
|
||||
background:radial-gradient(circle,
|
||||
rgba(var(--v5-primary-rgb),0.20),
|
||||
rgba(var(--v5-cyan-rgb),0.10) 50%,
|
||||
transparent 75%);
|
||||
animation:m-orb 6s ease-in-out infinite;
|
||||
z-index:0;
|
||||
pointer-events:none;
|
||||
}
|
||||
@keyframes m-orb{
|
||||
0%,100%{transform:scale(1); opacity:0.85}
|
||||
50% {transform:scale(1.15); opacity:1}
|
||||
}
|
||||
|
||||
/* ===== ALARM — 위험 모드 ===== */
|
||||
.m-shell-alarm{background:#0d0202; color:#fff}
|
||||
.m-shell-alarm .m-head{
|
||||
background:rgba(255,45,107,0.08);
|
||||
border-bottom-color:rgba(255,79,154,0.4);
|
||||
}
|
||||
.m-shell-alarm .m-brand{
|
||||
background:linear-gradient(135deg,#ff2d6b,#ff8a3a);
|
||||
-webkit-background-clip:text; background-clip:text; color:transparent;
|
||||
}
|
||||
|
||||
.m-alarm{flex:1 1 auto; position:relative; overflow:hidden}
|
||||
.m-alarm-bg{
|
||||
position:absolute; inset:0;
|
||||
background:
|
||||
radial-gradient(circle at 50% 30%, rgba(255,79,154,0.35), transparent 60%),
|
||||
repeating-linear-gradient(135deg,
|
||||
rgba(255,79,154,0.05) 0 14px,
|
||||
transparent 14px 28px);
|
||||
animation:m-bg-pulse 1.4s ease-in-out infinite;
|
||||
pointer-events:none;
|
||||
}
|
||||
@keyframes m-bg-pulse{0%,100%{opacity:0.85} 50%{opacity:1}}
|
||||
|
||||
.m-alarm-card{
|
||||
position:relative; height:100%;
|
||||
display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||
padding:32px 22px; text-align:center; gap:12px;
|
||||
animation:m-shake 0.55s ease-in-out 0s 3;
|
||||
}
|
||||
@keyframes m-shake{
|
||||
0%,100%{transform:translateX(0)}
|
||||
20%{transform:translateX(-6px)}
|
||||
40%{transform:translateX(6px)}
|
||||
60%{transform:translateX(-4px)}
|
||||
80%{transform:translateX(4px)}
|
||||
}
|
||||
|
||||
.m-alarm-sev{
|
||||
font-size:11px; letter-spacing:0.18em; font-weight:700;
|
||||
color:#fff; background:#ff2d6b;
|
||||
padding:4px 12px; border-radius:4px;
|
||||
box-shadow:0 0 14px rgba(255,45,107,0.6);
|
||||
}
|
||||
.m-alarm-icon{
|
||||
font-size:92px; line-height:1;
|
||||
filter:drop-shadow(0 0 22px rgba(255,79,154,0.7));
|
||||
animation:m-icon-pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes m-icon-pulse{
|
||||
0%,100%{transform:scale(1)}
|
||||
50% {transform:scale(1.08)}
|
||||
}
|
||||
.m-alarm-code{font-size:28px; font-weight:800; letter-spacing:0.08em; color:#ff8a3a}
|
||||
.m-alarm-title{font-size:18px; font-weight:600; color:#fff; max-width:340px; line-height:1.4}
|
||||
.m-alarm-loc{
|
||||
font-size:13px; color:#ffb380;
|
||||
background:rgba(255,138,58,0.1);
|
||||
padding:6px 14px; border-radius:14px;
|
||||
border:1px solid rgba(255,138,58,0.4);
|
||||
}
|
||||
.m-alarm-msg{
|
||||
font-size:14px; color:#ddd; line-height:1.6;
|
||||
white-space:pre-wrap; max-width:360px; margin-top:8px;
|
||||
}
|
||||
.m-alarm-actions{
|
||||
display:flex; flex-direction:column; gap:10px;
|
||||
width:100%; max-width:320px; margin-top:22px;
|
||||
}
|
||||
.m-btn-ack{
|
||||
background:linear-gradient(135deg,#ff2d6b,#ff8a3a);
|
||||
color:#fff; font-size:16px; font-weight:700; letter-spacing:0.04em;
|
||||
padding:16px; border:0; border-radius:12px;
|
||||
box-shadow:0 6px 20px rgba(255,45,107,0.4), 0 0 30px rgba(255,138,58,0.3);
|
||||
cursor:pointer;
|
||||
}
|
||||
.m-btn-ack:active{transform:translateY(1px)}
|
||||
.m-btn-skip{
|
||||
background:transparent; color:#aaa; font-size:13px;
|
||||
padding:10px; border:1px solid rgba(255,255,255,0.15); border-radius:10px;
|
||||
cursor:pointer;
|
||||
}
|
||||
.m-alarm-time{margin-top:14px; font-size:11px; color:#888; font-variant-numeric:tabular-nums}
|
||||
|
||||
/* 시연 안전망 — 우상단 hidden trigger */
|
||||
.m-test-trigger{
|
||||
position:fixed; top:8px; right:8px;
|
||||
width:18px; height:18px; border-radius:50%;
|
||||
background:rgba(var(--v5-primary-rgb),0.05);
|
||||
border:0; cursor:pointer; opacity:0.4;
|
||||
z-index:50;
|
||||
}
|
||||
.m-test-trigger:hover{opacity:0.9}
|
||||
Reference in New Issue
Block a user