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

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

354 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>