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:
2026-05-03 01:18:12 +09:00
parent 517c42b5cb
commit 7635412b7b
25 changed files with 5430 additions and 0 deletions
+1
View File
@@ -24,6 +24,7 @@ repositories {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-security'
@@ -0,0 +1,72 @@
package com.erp.alarm;
import com.erp.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* SCADA 데모(또는 실제 PLC 게이트웨이) 가 호출해서 작업자 폰으로 알람을 push 하는 엔드포인트.
* 인증은 SecurityConfig 단계에선 permitAll 이지만, 시연 한정이므로 미인증 호출 허용.
* 향후 운영 단계로 가면 ROLE_SYSTEM 같은 별도 가드 필요.
*/
@RestController
@RequestMapping("/api/demo/alarm")
@RequiredArgsConstructor
@Slf4j
public class AlarmController {
private final AlarmWebSocketHandler alarmHandler;
/**
* POST /api/demo/alarm/trigger
*
* body: {
* "target_user_id": "ky",
* "alarm": {
* "code": "P-IN-HH",
* "severity": "CRITICAL",
* "title": "BW-A1 펌프 과압 / 누설 의심",
* "message": "...",
* "location": "펌프룸 A · BW-A1",
* "comp": "bw-a1"
* }
* }
*/
@PostMapping("/trigger")
public ResponseEntity<ApiResponse<Map<String, Object>>> trigger(@RequestBody Map<String, Object> body) {
String targetUserId = (String) body.get("target_user_id");
if (!StringUtils.hasText(targetUserId)) {
return ResponseEntity.badRequest().body(ApiResponse.error("target_user_id 가 필요합니다."));
}
@SuppressWarnings("unchecked")
Map<String, Object> alarm = (Map<String, Object>) body.getOrDefault("alarm", new HashMap<>());
alarm.putIfAbsent("ts", System.currentTimeMillis());
int delivered = alarmHandler.sendAlarm(targetUserId, alarm);
Map<String, Object> result = new HashMap<>();
result.put("target_user_id", targetUserId);
result.put("delivered_to", delivered);
result.put("active_users", alarmHandler.activeUsers());
log.info("[Alarm] trigger user={}, delivered={}", targetUserId, delivered);
String msg = delivered > 0 ? "알람 전송됨 (" + delivered + "건)" : "수신자 미접속 — 알람 큐 없음";
return ResponseEntity.ok(ApiResponse.success(result, msg));
}
/** GET /api/demo/alarm/status — 현재 ws 접속 사용자 목록 (디버그/시연 안전망) */
@GetMapping("/status")
public ResponseEntity<ApiResponse<Map<String, Object>>> status() {
Map<String, Object> result = Map.of(
"active_users", alarmHandler.activeUsers(),
"active_count", alarmHandler.activeUsers().size()
);
return ResponseEntity.ok(ApiResponse.success(result, "ok"));
}
}
@@ -0,0 +1,68 @@
package com.erp.alarm;
import com.erp.security.JwtTokenProvider;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
import java.util.Map;
@Slf4j
@Component
@RequiredArgsConstructor
public class AlarmHandshakeInterceptor implements HandshakeInterceptor {
private final JwtTokenProvider jwtTokenProvider;
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> attributes) {
String token = extractToken(request);
if (token == null || !jwtTokenProvider.validateToken(token)) {
log.warn("[WS] handshake rejected: invalid or missing token (uri={})", request.getURI());
return false;
}
try {
Claims claims = jwtTokenProvider.getClaims(token);
String userId = claims.get("user_id", String.class);
String companyCode = claims.get("company_code", String.class);
if (userId == null) {
log.warn("[WS] handshake rejected: token has no user_id");
return false;
}
attributes.put("user_id", userId);
attributes.put("company_code", companyCode);
log.info("[WS] handshake ok: user={}, company={}", userId, companyCode);
return true;
} catch (Exception e) {
log.warn("[WS] handshake rejected: claims parse failed - {}", e.getMessage());
return false;
}
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Exception exception) {
// no-op
}
private String extractToken(ServerHttpRequest request) {
// 브라우저 WebSocket API 는 커스텀 헤더를 못 붙이므로 query string ?token= 우선.
// 비-브라우저 클라이언트(서버↔서버) 를 위해 Authorization: Bearer 도 fallback 으로 허용.
if (request instanceof ServletServerHttpRequest sreq) {
String q = sreq.getServletRequest().getParameter("token");
if (q != null && !q.isBlank()) return q;
}
String auth = request.getHeaders().getFirst("Authorization");
if (auth != null && auth.startsWith("Bearer ")) {
return auth.substring(7);
}
return null;
}
}
@@ -0,0 +1,93 @@
package com.erp.alarm;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Component
public class AlarmWebSocketHandler extends TextWebSocketHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
// userId -> set of sessions (한 사용자가 여러 디바이스/탭 동시 접속 가능)
private final Map<String, Set<WebSocketSession>> sessionsByUser = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String userId = (String) session.getAttributes().get("user_id");
if (userId == null) {
session.close(CloseStatus.POLICY_VIOLATION);
return;
}
sessionsByUser.computeIfAbsent(userId, k -> ConcurrentHashMap.newKeySet()).add(session);
log.info("[WS] connected: user={}, sessions={}", userId, sessionsByUser.get(userId).size());
Map<String, Object> hello = Map.of("type", "hello", "user_id", userId, "ts", System.currentTimeMillis());
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(hello)));
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
String userId = (String) session.getAttributes().get("user_id");
if (userId == null) return;
Set<WebSocketSession> set = sessionsByUser.get(userId);
if (set != null) {
set.remove(session);
if (set.isEmpty()) sessionsByUser.remove(userId);
}
log.info("[WS] closed: user={}, status={}", userId, status);
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
// 폰 → 서버 메시지 (예: ack). 시연 단계에선 ack 만 받아 로그.
String userId = (String) session.getAttributes().get("user_id");
log.info("[WS] msg from {}: {}", userId, message.getPayload());
}
/**
* 특정 user_id 의 모든 active session 에 알람 push.
* 반환값 = 실제로 메시지가 송신된 세션 수 (0 이면 미접속).
*/
public int sendAlarm(String userId, Map<String, Object> alarm) {
Set<WebSocketSession> set = sessionsByUser.get(userId);
if (set == null || set.isEmpty()) {
log.info("[WS] no active session for user={}, skipped", userId);
return 0;
}
Map<String, Object> envelope = Map.of("type", "alarm", "payload", alarm);
String json;
try {
json = objectMapper.writeValueAsString(envelope);
} catch (Exception e) {
log.error("[WS] serialize fail", e);
return 0;
}
int sent = 0;
for (WebSocketSession s : set) {
try {
if (s.isOpen()) {
s.sendMessage(new TextMessage(json));
sent++;
}
} catch (Exception e) {
log.warn("[WS] send fail to {}: {}", userId, e.getMessage());
}
}
return sent;
}
/** 디버그/모니터링 용 */
public Set<String> activeUsers() {
return sessionsByUser.keySet();
}
}
@@ -0,0 +1,26 @@
package com.erp.alarm;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
private final AlarmWebSocketHandler alarmHandler;
private final AlarmHandshakeInterceptor handshakeInterceptor;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// path 를 /api/demo/* 안에 두는 이유:
// 운영 Traefik 라우트가 PathPrefix(`/api`) 만 backend 로 보내므로 별도 라우트 추가가 필요 없음.
// /demo/* 는 시연용임을 path 에서 명시 — 정식 알람 시스템(/api/alarm/*)과 분리.
registry.addHandler(alarmHandler, "/api/demo/ws/alarm")
.addInterceptors(handshakeInterceptor)
.setAllowedOriginPatterns("*");
}
}
+35
View File
@@ -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>
);
}
+10
View File
@@ -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>
);
}
+5
View File
@@ -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; }
+562
View File
@@ -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일 남음. 핵심 기능은 완성. 내일은 시각 미세조정 + 시연 시나리오 정리.
+115
View File
@@ -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

+353
View File
@@ -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&amp;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>
+566
View File
@@ -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);
+539
View File
@@ -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);
+44
View File
@@ -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();
})();
+250
View File
@@ -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);
+610
View File
@@ -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);
+513
View File
@@ -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.
+167
View File
@@ -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}