feat: SCADA 데모 음성 인식 + 경고 버튼 디자인 통일
Build & Deploy to K8s / build-and-deploy (push) Failing after 1m14s
Build & Deploy to K8s / build-and-deploy (push) Failing after 1m14s
- 음성 인식 (scada-demo/js/voice.js) — 한국어 발화 → 키워드 매핑 → INVYONE_UI.select() · 사이드바 마이크 버튼 + transcript 라벨, 매칭 시 청록 펄스 · Chrome/Edge HTTPS 환경 (운영 siflex.invyone.com OK) - 경고시스템/다중경고 버튼을 음성 인식과 동일 톤 · 🚨 emoji → SVG 삼각형 아이콘, voice-btn 패턴 (다크 솔리드 + 컬러 액센트) · 정적 (반짝 펄스 애니메이션 제거) - client.ts stash pop conflict 정리 (DEV_TENANT_HOST + 도메인 정리 통합) - ui.js 다중 경고 시연 wiring + scada 작업 노트 2건 - 기타 syncthing 보류분 batch (대시보드/레이아웃/로그인 layout 정리) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>SCADA Component Library</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; padding: 24px;
|
||||
background: #050a18;
|
||||
color: #fff;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
}
|
||||
h1 {
|
||||
font-size: 18px; margin: 0 0 4px;
|
||||
color: #5af; letter-spacing: 1px;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 12px; color: #8aa; margin-bottom: 20px;
|
||||
}
|
||||
.lib {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.card {
|
||||
background: #0a1428;
|
||||
border: 1px solid #1e3060;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.card h3 {
|
||||
font-size: 13px;
|
||||
margin: 0 0 8px;
|
||||
color: #5af;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.badge {
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.badge.ok { background: #1a5a2a; color: #7cff3a; border: 1px solid #2a8b3a; }
|
||||
.badge.fail { background: #5a1a1a; color: #ff5a5a; border: 1px solid #8b2a2a; }
|
||||
.stage {
|
||||
background: #050a18;
|
||||
border: 1px solid #1e3060;
|
||||
padding: 12px;
|
||||
min-height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.meta {
|
||||
margin-top: 8px;
|
||||
font-size: 10px;
|
||||
color: #8aa;
|
||||
font-family: 'Consolas', monospace;
|
||||
border-top: 1px dashed #1e3060;
|
||||
padding-top: 6px;
|
||||
}
|
||||
.meta .key { color: #5af; }
|
||||
.meta div { margin: 2px 0; word-break: break-all; }
|
||||
.ctrl {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ctrl button {
|
||||
background: #0a1830;
|
||||
border: 1px solid #1e3060;
|
||||
color: #cfd3d8;
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ctrl button.active {
|
||||
background: #1a5a2a;
|
||||
border-color: #5af04a;
|
||||
color: #fff;
|
||||
}
|
||||
.ctrl button:hover { filter: brightness(1.3); }
|
||||
|
||||
.empty-card {
|
||||
background: #0a1428;
|
||||
border: 1px dashed #1e3060;
|
||||
border-radius: 6px;
|
||||
padding: 24px;
|
||||
color: #4a6a8a;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>SCADA Component Library</h1>
|
||||
<div class="subtitle">검증 통과한 SVG 컴포넌트 카탈로그 — 인스턴스 prefix 적용 전 디자인/동작 확인용</div>
|
||||
|
||||
<div class="lib" id="lib"></div>
|
||||
|
||||
<script>
|
||||
//==============================================================
|
||||
// 컴포넌트 등록 — 검증 통과한 SVG 만 추가
|
||||
// 다음 컴포넌트 받으면 LIBRARY 배열에 push
|
||||
//==============================================================
|
||||
const LIBRARY = [
|
||||
{
|
||||
id: 'pipe-straight',
|
||||
title: '#18 Pipe Straight (horizontal)',
|
||||
viewBox: '0 0 100 20',
|
||||
displayWidth: 240,
|
||||
displayHeight: 48,
|
||||
requiredIds: ['pipe-outer', 'pipe-inner', 'pipe-flow'],
|
||||
notes: 'CSS rotate(90deg) 로 수직 파이프 재사용 가능',
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20" overflow="visible" role="img" aria-label="SCADA straight pipe" style="--scada-stroke:#aaaaaa;--scada-stroke-strong:#ffffff;--scada-flow:#5af9ff;--scada-active:#7cff3a;--scada-warning:#ff8a3a;--scada-alarm:#ff4f9a;--scada-idle:#2a3f5a">
|
||||
<defs>
|
||||
<linearGradient id="pipe-metal-grad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#d9d9d9"/>
|
||||
<stop offset="0.18" stop-color="#f4f4f4"/>
|
||||
<stop offset="0.5" stop-color="#8f8f8f"/>
|
||||
<stop offset="0.82" stop-color="#d7d7d7"/>
|
||||
<stop offset="1" stop-color="#6f6f6f"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<style>
|
||||
.scada-stroke{stroke:var(--scada-stroke);stroke-width:1;vector-effect:non-scaling-stroke}
|
||||
.scada-strong{stroke:var(--scada-stroke-strong);stroke-width:1;vector-effect:non-scaling-stroke}
|
||||
.scada-fill-idle{fill:var(--scada-idle)}
|
||||
.scada-fill-metal{fill:url(#pipe-metal-grad)}
|
||||
.flow-line{stroke:var(--scada-flow);stroke-width:2;stroke-linecap:round;stroke-dasharray:4 4;vector-effect:non-scaling-stroke}
|
||||
</style>
|
||||
<rect x="2" y="4" width="96" height="12" rx="2" ry="2" id="pipe-outer" class="scada-fill-metal scada-stroke"/>
|
||||
<rect x="6" y="7" width="88" height="6" rx="1.5" ry="1.5" id="pipe-inner" class="scada-fill-idle scada-stroke"/>
|
||||
<line x1="8" y1="5" x2="8" y2="15" class="scada-strong"/>
|
||||
<line x1="12" y1="5" x2="12" y2="15" class="scada-stroke"/>
|
||||
<line x1="88" y1="5" x2="88" y2="15" class="scada-stroke"/>
|
||||
<line x1="92" y1="5" x2="92" y2="15" class="scada-strong"/>
|
||||
<line x1="12" y1="10" x2="88" y2="10" id="pipe-flow" class="flow-line"/>
|
||||
</svg>`,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'pipe-elbow',
|
||||
title: '#19 Pipe Elbow 90° (┐ base)',
|
||||
viewBox: '0 0 50 50',
|
||||
displayWidth: 120,
|
||||
displayHeight: 120,
|
||||
requiredIds: ['pipe-outer', 'pipe-inner', 'pipe-flow'],
|
||||
notes: 'CSS rotate(90/180/270) 로 ┘/└/┌ 재사용. stroke 방식이라 곡선 깔끔',
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" overflow="visible" role="img" aria-label="SCADA pipe elbow 90 degrees" style="--scada-stroke:#aaaaaa;--scada-stroke-strong:#ffffff;--scada-flow:#5af9ff;--scada-active:#7cff3a;--scada-warning:#ff8a3a;--scada-alarm:#ff4f9a;--scada-idle:#2a3f5a">
|
||||
<defs>
|
||||
<linearGradient id="pipe-metal-grad-19" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#d9d9d9"/>
|
||||
<stop offset="0.18" stop-color="#f4f4f4"/>
|
||||
<stop offset="0.5" stop-color="#8f8f8f"/>
|
||||
<stop offset="0.82" stop-color="#d7d7d7"/>
|
||||
<stop offset="1" stop-color="#6f6f6f"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<style>
|
||||
.e19-stroke{stroke:var(--scada-stroke);stroke-width:1;vector-effect:non-scaling-stroke}
|
||||
.e19-outer{fill:none;stroke:url(#pipe-metal-grad-19);stroke-width:12;stroke-linecap:butt;stroke-linejoin:round;vector-effect:non-scaling-stroke}
|
||||
.e19-inner{fill:none;stroke:var(--scada-idle);stroke-width:6;stroke-linecap:butt;stroke-linejoin:round;vector-effect:non-scaling-stroke}
|
||||
.e19-flow{fill:none;stroke:var(--scada-flow);stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:4 4;vector-effect:non-scaling-stroke}
|
||||
</style>
|
||||
<path id="pipe-outer" class="e19-outer e19-stroke" d="M2 14 H24 A12 12 0 0 1 36 26 V48"/>
|
||||
<path id="pipe-inner" class="e19-inner e19-stroke" d="M2 14 H24 A12 12 0 0 1 36 26 V48"/>
|
||||
<path id="pipe-flow" class="e19-flow" d="M4 14 H24 A12 12 0 0 1 36 26 V46"/>
|
||||
</svg>`,
|
||||
},
|
||||
{
|
||||
id: 'pipe-tjunction',
|
||||
title: '#20 Pipe T-junction (┬ base)',
|
||||
viewBox: '0 0 60 40',
|
||||
displayWidth: 180,
|
||||
displayHeight: 120,
|
||||
requiredIds: ['pipe-main-outer', 'pipe-main-inner', 'pipe-main-flow', 'pipe-branch-outer', 'pipe-branch-inner', 'pipe-branch-flow'],
|
||||
notes: '한 덩어리 path + 좌/우/아래 평평한 단면 + flange 3쌍. branch-outer/inner 는 hidden helper (실 외곽은 main path 가 T자 전체 그림). CSS rotate 로 ┤/┴/├',
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 40" overflow="visible" role="img" aria-label="SCADA pipe T-junction" style="--scada-stroke:#aaaaaa;--scada-stroke-strong:#ffffff;--scada-flow:#5af9ff;--scada-active:#7cff3a;--scada-warning:#ff8a3a;--scada-alarm:#ff4f9a;--scada-idle:#2a3f5a">
|
||||
<defs>
|
||||
<linearGradient id="pipe-metal-grad-20" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#d9d9d9"/>
|
||||
<stop offset="0.18" stop-color="#f4f4f4"/>
|
||||
<stop offset="0.5" stop-color="#8f8f8f"/>
|
||||
<stop offset="0.82" stop-color="#d7d7d7"/>
|
||||
<stop offset="1" stop-color="#6f6f6f"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<style>
|
||||
.t20-stroke{stroke:var(--scada-stroke);stroke-width:1;vector-effect:non-scaling-stroke}
|
||||
.t20-strong{stroke:var(--scada-stroke-strong);stroke-width:1;vector-effect:non-scaling-stroke}
|
||||
.t20-metal{fill:url(#pipe-metal-grad-20)}
|
||||
.t20-idle{fill:var(--scada-idle)}
|
||||
.t20-flow{fill:none;stroke:var(--scada-flow);stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:4 4;vector-effect:non-scaling-stroke}
|
||||
.t20-hidden{fill:none;stroke:none}
|
||||
</style>
|
||||
<path id="pipe-main-outer" d="M2 4H58V16H38Q36 16 36 18V38H24V18Q24 16 22 16H2Z" class="t20-metal t20-stroke"/>
|
||||
<path id="pipe-main-inner" d="M6 7H54V13H34Q33 13 33 14V34H27V14Q27 13 26 13H6Z" class="t20-idle t20-stroke"/>
|
||||
<path id="pipe-branch-outer" d="M24 16H36V38H24Z" class="t20-hidden"/>
|
||||
<path id="pipe-branch-inner" d="M27 14H33V34H27Z" class="t20-hidden"/>
|
||||
<line x1="8" y1="5" x2="8" y2="15" class="t20-strong"/>
|
||||
<line x1="12" y1="5" x2="12" y2="15" class="t20-stroke"/>
|
||||
<line x1="48" y1="5" x2="48" y2="15" class="t20-stroke"/>
|
||||
<line x1="52" y1="5" x2="52" y2="15" class="t20-strong"/>
|
||||
<line x1="25" y1="28" x2="35" y2="28" class="t20-stroke"/>
|
||||
<line x1="25" y1="32" x2="35" y2="32" class="t20-strong"/>
|
||||
<line id="pipe-main-flow" x1="12" y1="10" x2="48" y2="10" class="t20-flow"/>
|
||||
<line id="pipe-branch-flow" x1="30" y1="16" x2="30" y2="32" class="t20-flow"/>
|
||||
</svg>`,
|
||||
},
|
||||
{
|
||||
id: 'pipe-dynamic',
|
||||
title: '#18b Pipe Straight — Dynamic Length Demo',
|
||||
viewBox: '동적 (length 슬라이더로 가변)',
|
||||
displayWidth: null,
|
||||
displayHeight: null,
|
||||
requiredIds: ['pipe-outer', 'pipe-inner', 'pipe-flow'],
|
||||
notes: 'JS 가 length px 받아서 양 끝 flange 고정 + 가운데만 늘어남. 흐름량 슬라이더로 흐르는 속도(L/min) 도 가변',
|
||||
dynamic: 'length',
|
||||
minLength: 80,
|
||||
maxLength: 800,
|
||||
initialLength: 300,
|
||||
},
|
||||
{ id: 'placeholder-11', title: '#11 Centrifugal Pump', placeholder: true },
|
||||
{ id: 'placeholder-14', title: '#14 Gate Valve', placeholder: true },
|
||||
];
|
||||
|
||||
//==============================================================
|
||||
// 동적 SVG 생성 — pipe straight 의 어떤 길이도 자동 생성
|
||||
// 양 끝 flange 좌표는 절대값, 가운데 outer/inner/flow 만 length 따라 늘어남
|
||||
//==============================================================
|
||||
function createPipeStraightSVG(lengthPx) {
|
||||
const W = lengthPx;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} 20" width="${W}" height="20" overflow="visible" role="img" style="--scada-stroke:#aaaaaa;--scada-stroke-strong:#ffffff;--scada-flow:#5af9ff;--scada-idle:#2a3f5a">
|
||||
<defs>
|
||||
<linearGradient id="pipe-metal-grad-dyn" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#d9d9d9"/>
|
||||
<stop offset="0.18" stop-color="#f4f4f4"/>
|
||||
<stop offset="0.5" stop-color="#8f8f8f"/>
|
||||
<stop offset="0.82" stop-color="#d7d7d7"/>
|
||||
<stop offset="1" stop-color="#6f6f6f"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<style>
|
||||
.pdyn-stroke{stroke:var(--scada-stroke);stroke-width:1;vector-effect:non-scaling-stroke}
|
||||
.pdyn-strong{stroke:var(--scada-stroke-strong);stroke-width:1;vector-effect:non-scaling-stroke}
|
||||
.pdyn-metal{fill:url(#pipe-metal-grad-dyn)}
|
||||
.pdyn-idle{fill:var(--scada-idle)}
|
||||
.pdyn-flow{stroke:var(--scada-flow);stroke-width:2;stroke-linecap:round;stroke-dasharray:4 4;vector-effect:non-scaling-stroke}
|
||||
</style>
|
||||
<rect id="pipe-outer" x="2" y="4" width="${W - 4}" height="12" rx="2" ry="2" class="pdyn-metal pdyn-stroke"/>
|
||||
<rect id="pipe-inner" x="6" y="7" width="${W - 12}" height="6" rx="1.5" ry="1.5" class="pdyn-idle pdyn-stroke"/>
|
||||
<line x1="8" y1="5" x2="8" y2="15" class="pdyn-strong"/>
|
||||
<line x1="12" y1="5" x2="12" y2="15" class="pdyn-stroke"/>
|
||||
<line x1="${W - 12}" y1="5" x2="${W - 12}" y2="15" class="pdyn-stroke"/>
|
||||
<line x1="${W - 8}" y1="5" x2="${W - 8}" y2="15" class="pdyn-strong"/>
|
||||
<line id="pipe-flow" x1="12" y1="10" x2="${W - 12}" y2="10" class="pdyn-flow"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
//==============================================================
|
||||
// 카드 렌더
|
||||
//==============================================================
|
||||
const STATE_COLORS = {
|
||||
active: '#7cff3a',
|
||||
warning: '#ff8a3a',
|
||||
alarm: '#ff4f9a',
|
||||
idle: '#2a3f5a',
|
||||
};
|
||||
|
||||
const lib = document.getElementById('lib');
|
||||
|
||||
LIBRARY.forEach(comp => {
|
||||
if (comp.placeholder) {
|
||||
const ph = document.createElement('div');
|
||||
ph.className = 'empty-card';
|
||||
ph.textContent = '⌛ ' + comp.title + ' — 대기 중';
|
||||
lib.appendChild(ph);
|
||||
return;
|
||||
}
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
|
||||
const header = document.createElement('h3');
|
||||
header.innerHTML = `<span>${comp.title}</span>`;
|
||||
card.appendChild(header);
|
||||
|
||||
const stage = document.createElement('div');
|
||||
stage.className = 'stage';
|
||||
card.appendChild(stage);
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'meta';
|
||||
card.appendChild(meta);
|
||||
|
||||
const ctrl = document.createElement('div');
|
||||
ctrl.className = 'ctrl';
|
||||
card.appendChild(ctrl);
|
||||
|
||||
const flowCtrl = document.createElement('div');
|
||||
flowCtrl.style.marginTop = '8px';
|
||||
card.appendChild(flowCtrl);
|
||||
|
||||
let lengthCtrl = null;
|
||||
if (comp.dynamic === 'length') {
|
||||
lengthCtrl = document.createElement('div');
|
||||
lengthCtrl.style.marginTop = '6px';
|
||||
card.appendChild(lengthCtrl);
|
||||
}
|
||||
|
||||
card._flowActive = true;
|
||||
card._flowRate = 50;
|
||||
card._flowOffset = 0;
|
||||
let currentRotation = 0;
|
||||
|
||||
function paintSvg(svgHtml) {
|
||||
stage.innerHTML = svgHtml;
|
||||
const svgEl = stage.querySelector('svg');
|
||||
if (comp.displayWidth) svgEl.setAttribute('width', comp.displayWidth);
|
||||
if (comp.displayHeight) svgEl.setAttribute('height', comp.displayHeight);
|
||||
if (currentRotation) svgEl.style.transform = `rotate(${currentRotation}deg)`;
|
||||
|
||||
// 검증
|
||||
const allIds = Array.from(svgEl.querySelectorAll('[id]')).map(el => el.id);
|
||||
const missing = comp.requiredIds.filter(id => !allIds.includes(id));
|
||||
const ok = missing.length === 0;
|
||||
|
||||
const oldBadge = header.querySelector('.badge');
|
||||
if (oldBadge) oldBadge.remove();
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge ' + (ok ? 'ok' : 'fail');
|
||||
badge.textContent = ok ? '✓ VERIFIED' : '✗ MISSING ' + missing.length;
|
||||
header.appendChild(badge);
|
||||
|
||||
meta.innerHTML = `
|
||||
<div><span class="key">viewBox</span>: ${svgEl.getAttribute('viewBox') || comp.viewBox}</div>
|
||||
<div><span class="key">IDs</span> (${allIds.length}): ${allIds.join(', ')}</div>
|
||||
${comp.notes ? `<div><span class="key">notes</span>: ${comp.notes}</div>` : ''}
|
||||
`;
|
||||
|
||||
return svgEl;
|
||||
}
|
||||
|
||||
// 초기 SVG
|
||||
let svgEl = comp.dynamic === 'length'
|
||||
? paintSvg(createPipeStraightSVG(comp.initialLength))
|
||||
: paintSvg(comp.svg);
|
||||
|
||||
const hasFlow = svgEl.querySelectorAll('[id$="-flow"], [id="pipe-flow"]').length > 0;
|
||||
|
||||
if (hasFlow) {
|
||||
// 상태 토글
|
||||
['active', 'warning', 'alarm', 'idle'].forEach(state => {
|
||||
const btn = document.createElement('button');
|
||||
btn.dataset.state = state;
|
||||
btn.textContent = state;
|
||||
btn.addEventListener('click', () => {
|
||||
ctrl.querySelectorAll('button[data-state]').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
const cur = stage.querySelector('svg');
|
||||
cur.style.setProperty('--scada-flow', STATE_COLORS[state]);
|
||||
card._flowActive = (state !== 'idle');
|
||||
});
|
||||
ctrl.appendChild(btn);
|
||||
});
|
||||
|
||||
// 회전 버튼
|
||||
[0, 90, 180, 270].forEach(deg => {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = deg + '°';
|
||||
if (deg === 0) btn.style.marginLeft = '12px';
|
||||
btn.addEventListener('click', () => {
|
||||
currentRotation = deg;
|
||||
const cur = stage.querySelector('svg');
|
||||
cur.style.transform = `rotate(${deg}deg)`;
|
||||
});
|
||||
ctrl.appendChild(btn);
|
||||
});
|
||||
|
||||
// flow rate 슬라이더 (유량)
|
||||
flowCtrl.innerHTML = `
|
||||
<label style="display:flex;justify-content:space-between;font-size:10px;color:#8aa;font-family:Consolas">
|
||||
<span>유량 (Flow rate)</span>
|
||||
<span><b data-out>50</b> L/min</span>
|
||||
</label>
|
||||
<input type="range" min="0" max="100" value="50" step="1" style="width:100%;margin-top:2px">
|
||||
`;
|
||||
const flowSlider = flowCtrl.querySelector('input');
|
||||
const flowOut = flowCtrl.querySelector('[data-out]');
|
||||
flowSlider.addEventListener('input', () => {
|
||||
card._flowRate = +flowSlider.value;
|
||||
flowOut.textContent = card._flowRate;
|
||||
card._flowActive = card._flowRate > 0;
|
||||
flowOut.style.color = card._flowRate === 0 ? '#666' : card._flowRate > 70 ? '#7cff3a' : '#5af9ff';
|
||||
});
|
||||
|
||||
// 기본: active
|
||||
ctrl.querySelector('[data-state="active"]').click();
|
||||
|
||||
// 애니메이션 — flowRate 가 속도 결정 (0=정지, 100=최대)
|
||||
setInterval(() => {
|
||||
if (!card._flowActive) return;
|
||||
const speed = card._flowRate / 100 * 1.8;
|
||||
card._flowOffset -= speed;
|
||||
const cur = stage.querySelector('svg');
|
||||
if (cur) {
|
||||
cur.querySelectorAll('[id$="-flow"], [id="pipe-flow"]').forEach(el => {
|
||||
el.setAttribute('stroke-dashoffset', card._flowOffset);
|
||||
});
|
||||
}
|
||||
}, 30);
|
||||
}
|
||||
|
||||
// dynamic length: 슬라이더로 SVG 재생성
|
||||
if (comp.dynamic === 'length' && lengthCtrl) {
|
||||
lengthCtrl.innerHTML = `
|
||||
<label style="display:flex;justify-content:space-between;font-size:10px;color:#8aa;font-family:Consolas">
|
||||
<span>파이프 길이</span>
|
||||
<span><b data-out>${comp.initialLength}</b> px</span>
|
||||
</label>
|
||||
<input type="range" min="${comp.minLength}" max="${comp.maxLength}" value="${comp.initialLength}" step="10" style="width:100%;margin-top:2px">
|
||||
`;
|
||||
const lenSlider = lengthCtrl.querySelector('input');
|
||||
const lenOut = lengthCtrl.querySelector('[data-out]');
|
||||
lenSlider.addEventListener('input', () => {
|
||||
const len = +lenSlider.value;
|
||||
lenOut.textContent = len;
|
||||
paintSvg(createPipeStraightSVG(len));
|
||||
// 현재 활성 상태 색 다시 입히기
|
||||
const activeBtn = ctrl.querySelector('button[data-state].active');
|
||||
if (activeBtn) {
|
||||
const state = activeBtn.dataset.state;
|
||||
const cur = stage.querySelector('svg');
|
||||
cur.style.setProperty('--scada-flow', STATE_COLORS[state]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
lib.appendChild(card);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,555 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>AUTO CHEMICAL SUPPLY MONITORING WEB SYSTEM</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; padding: 0; background: #050a18; color: #fff;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(180deg, #1a3870 0%, #0c1f4a 100%);
|
||||
padding: 8px 20px; display: flex; justify-content: space-between;
|
||||
align-items: center; border-bottom: 1px solid #1e4080;
|
||||
}
|
||||
.header h1 { font-size: 18px; margin: 0; letter-spacing: 2px; font-weight: 700; }
|
||||
.header .clock { font-size: 14px; font-family: 'Consolas', monospace; }
|
||||
|
||||
.main {
|
||||
display: grid; grid-template-columns: 1fr 380px; gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tanks-area {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr) 1.4fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.tank-cell {
|
||||
background: #050a18;
|
||||
border: 1px solid #1e3060;
|
||||
padding: 6px;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.tank-cell .label {
|
||||
font-size: 13px; font-weight: 600; margin-bottom: 4px; color: #fff;
|
||||
}
|
||||
.hno3-group {
|
||||
grid-column: 5 / 6; grid-row: 1 / 2;
|
||||
background: #050a18;
|
||||
border: 1px dashed #5a8;
|
||||
padding: 8px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.hno3-group .group-title {
|
||||
text-align: center; font-size: 13px; font-weight: 600;
|
||||
color: #5af; margin-bottom: 4px;
|
||||
}
|
||||
.hno3-group .group-tanks {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
.hno3-group .tank-cell { border: none; padding: 2px; }
|
||||
|
||||
.h2so4-cell { grid-column: 1 / 3; grid-row: 2 / 3; }
|
||||
.h2o2-cell { grid-column: 3 / 5; grid-row: 2 / 3; }
|
||||
|
||||
.gauge-row {
|
||||
margin-top: 4px;
|
||||
background: #0a1830; border: 1px solid #1a2f50;
|
||||
padding: 4px 6px; width: 100%;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
}
|
||||
.gauge-row .gauge-label {
|
||||
font-size: 9px; color: #8aa; margin-bottom: 2px;
|
||||
}
|
||||
.gauge-bars {
|
||||
display: flex; gap: 2px; height: 10px; width: 100%;
|
||||
}
|
||||
.gauge-bars > div {
|
||||
flex: 1; transition: background 0.3s;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.panel-box {
|
||||
border: 1px solid #2a4070;
|
||||
background: #0a1830;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.panel-box .panel-title {
|
||||
background: linear-gradient(180deg, #1a3870, #0c1f4a);
|
||||
color: #5af; text-align: center; padding: 4px;
|
||||
font-size: 12px; font-weight: 600;
|
||||
}
|
||||
.panel-grid {
|
||||
display: grid; gap: 4px; padding: 6px;
|
||||
}
|
||||
.panel-grid > div {
|
||||
padding: 6px 4px; text-align: center; font-size: 11px; font-weight: 600;
|
||||
border-radius: 3px; cursor: pointer; transition: filter 0.2s;
|
||||
}
|
||||
.panel-grid > div:hover { filter: brightness(1.3); }
|
||||
.grid-5 { grid-template-columns: repeat(5, 1fr); }
|
||||
.grid-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.cell-green { background: #2a8b3a; color: #fff; }
|
||||
.cell-pink { background: #d04880; color: #fff; }
|
||||
|
||||
.data-table {
|
||||
border: 1px solid #2a4070;
|
||||
background: #050a18;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.data-table .panel-title {
|
||||
background: linear-gradient(180deg, #1a3870, #0c1f4a);
|
||||
color: #5af; text-align: center; padding: 4px;
|
||||
font-size: 12px; font-weight: 600;
|
||||
}
|
||||
.data-table table {
|
||||
width: 100%; border-collapse: collapse; font-size: 10px;
|
||||
font-family: 'Consolas', monospace;
|
||||
}
|
||||
.data-table th, .data-table td {
|
||||
border: 1px solid #1a2f50;
|
||||
padding: 2px 4px; text-align: center;
|
||||
}
|
||||
.data-table thead { background: #0c1f4a; }
|
||||
.data-table .group-th {
|
||||
background: #142850; color: #5af; font-weight: 600;
|
||||
}
|
||||
.data-table tbody tr.fresh { animation: flash 1s ease-out; }
|
||||
@keyframes flash {
|
||||
0% { background: rgba(90, 255, 200, 0.4); }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: #1a3a1a; color: #5af04a;
|
||||
padding: 4px 20px; font-size: 12px; font-weight: 600;
|
||||
border-top: 1px solid #1e6020;
|
||||
}
|
||||
|
||||
/* 뚜껑 — 우측 끝 (89, 35) 을 경첩으로 -90도 회전 */
|
||||
.tank-lid {
|
||||
transform-origin: 89px 35px;
|
||||
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tank-lid:hover { filter: brightness(1.15); }
|
||||
.tank-lid.open { transform: rotate(90deg); }
|
||||
.tank-cell { overflow: visible; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>AUTO CHEMICAL SUPPLY MONITORING WEB SYSTEM</h1>
|
||||
<div class="clock" id="clock">--:--:-- --/--/----</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<!-- LEFT: 탱크 영역 -->
|
||||
<div class="tanks-area" id="tanksArea">
|
||||
<div class="tank-cell" id="cell-HCL"></div>
|
||||
<div class="tank-cell" id="cell-CuCl2"></div>
|
||||
<div class="tank-cell" id="cell-OXA"></div>
|
||||
|
||||
<!-- 4번째 자리는 비고 (HNO3 그룹이 5열 1행에 들어감) -->
|
||||
<div></div>
|
||||
|
||||
<div class="hno3-group">
|
||||
<div class="group-title">HNO3 - AU PLATING</div>
|
||||
<div class="group-tanks">
|
||||
<div class="tank-cell" id="cell-HNO3"></div>
|
||||
<div class="tank-cell" id="cell-AU"></div>
|
||||
<div class="tank-cell" id="cell-WHNO3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tank-cell h2so4-cell" id="cell-H2SO4"></div>
|
||||
<div class="tank-cell h2o2-cell" id="cell-H2O2"></div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: 패널 + 테이블 -->
|
||||
<div class="right-panel">
|
||||
<div class="panel-box">
|
||||
<div class="panel-title">SUPPLY CHEMICAL TO STORAGE TANK</div>
|
||||
<div class="panel-grid grid-5">
|
||||
<div class="cell-green">HCL</div>
|
||||
<div class="cell-green">OXA</div>
|
||||
<div class="cell-green">HNO3</div>
|
||||
<div class="cell-green">H2O2</div>
|
||||
<div class="cell-green">H2SO4</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-box">
|
||||
<div class="panel-title">WASTE CHEMICAL</div>
|
||||
<div class="panel-grid grid-2">
|
||||
<div class="cell-green">CuCl2</div>
|
||||
<div class="cell-green">HNO3 WASTE</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-box">
|
||||
<div class="panel-title">SUPPLY CHEMICAL TO PRODUCTION</div>
|
||||
<div class="panel-grid grid-3">
|
||||
<div class="cell-green">CuCl2 DES#1</div>
|
||||
<div class="cell-green">HCL DES#1</div>
|
||||
<div class="cell-green">HCL CF2</div>
|
||||
<div class="cell-green">OXA DES#1</div>
|
||||
<div class="cell-pink">HNO3 AU</div>
|
||||
<div class="cell-pink">H2SO4 COPPER</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="data-table">
|
||||
<div class="panel-title">SUPPLY AMOUNT OF COPPER PLATING ROOM</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2">TIME</th>
|
||||
<th colspan="2" class="group-th">HCL (L)</th>
|
||||
<th colspan="2" class="group-th">CuCl2 (L)</th>
|
||||
<th colspan="2" class="group-th">OXA (L)</th>
|
||||
<th colspan="2" class="group-th">HNO3 (L)</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>FLOW</th><th>TOTAL</th>
|
||||
<th>FLOW</th><th>TOTAL</th>
|
||||
<th>FLOW</th><th>TOTAL</th>
|
||||
<th>FLOW</th><th>TOTAL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dataBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">● System connected normally</div>
|
||||
|
||||
<script>
|
||||
//==============================================================
|
||||
// SVG 템플릿 (ChatGPT GPT-5 가 생성한 산업용 탱크)
|
||||
// — id 와 url(#) 은 인스턴스마다 prefix 치환됨
|
||||
//==============================================================
|
||||
const TANK_SVG_TEMPLATE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 180" overflow="visible" role="img" aria-label="Industrial chemical storage tank">
|
||||
<defs>
|
||||
<linearGradient id="metal-grad" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="var(--metal-dark)"/>
|
||||
<stop offset="0.18" stop-color="var(--metal-light)"/>
|
||||
<stop offset="0.42" stop-color="#ffffff"/>
|
||||
<stop offset="0.72" stop-color="var(--metal-dark)"/>
|
||||
<stop offset="1" stop-color="var(--metal-light)"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="glass-grad" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#ffffff" stop-opacity="0.18"/>
|
||||
<stop offset="0.12" stop-color="#ffffff" stop-opacity="0.04"/>
|
||||
<stop offset="0.5" stop-color="#0b1528" stop-opacity="0.35"/>
|
||||
<stop offset="0.88" stop-color="#ffffff" stop-opacity="0.06"/>
|
||||
<stop offset="1" stop-color="#ffffff" stop-opacity="0.16"/>
|
||||
</linearGradient>
|
||||
<clipPath id="tank-clip">
|
||||
<rect x="31" y="40" width="56" height="113"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<style>
|
||||
.tank-num{fill:#cfd3d8;font-family:Arial,Helvetica,sans-serif;font-size:7px;text-anchor:end}
|
||||
.tank-mono{fill:#ffffff;font-family:Consolas,'Courier New',monospace;font-size:13px;text-anchor:middle;dominant-baseline:middle}
|
||||
</style>
|
||||
<rect x="0" y="0" width="110" height="180" fill="#050a18"/>
|
||||
|
||||
<!-- Scale ruler -->
|
||||
<g>
|
||||
<line x1="22" y1="40" x2="22" y2="155" stroke="#ffffff" stroke-width="1" vector-effect="non-scaling-stroke"/>
|
||||
<g stroke="#ffffff" stroke-width="1" vector-effect="non-scaling-stroke">
|
||||
<line x1="17" y1="155" x2="22" y2="155"/>
|
||||
<line x1="18.5" y1="149.25" x2="22" y2="149.25"/>
|
||||
<line x1="18.5" y1="143.5" x2="22" y2="143.5"/>
|
||||
<line x1="18.5" y1="137.75" x2="22" y2="137.75"/>
|
||||
<line x1="17" y1="132" x2="22" y2="132"/>
|
||||
<line x1="18.5" y1="126.25" x2="22" y2="126.25"/>
|
||||
<line x1="18.5" y1="120.5" x2="22" y2="120.5"/>
|
||||
<line x1="18.5" y1="114.75" x2="22" y2="114.75"/>
|
||||
<line x1="17" y1="109" x2="22" y2="109"/>
|
||||
<line x1="18.5" y1="103.25" x2="22" y2="103.25"/>
|
||||
<line x1="18.5" y1="97.5" x2="22" y2="97.5"/>
|
||||
<line x1="18.5" y1="91.75" x2="22" y2="91.75"/>
|
||||
<line x1="17" y1="86" x2="22" y2="86"/>
|
||||
<line x1="18.5" y1="80.25" x2="22" y2="80.25"/>
|
||||
<line x1="18.5" y1="74.5" x2="22" y2="74.5"/>
|
||||
<line x1="18.5" y1="68.75" x2="22" y2="68.75"/>
|
||||
<line x1="17" y1="63" x2="22" y2="63"/>
|
||||
<line x1="18.5" y1="57.25" x2="22" y2="57.25"/>
|
||||
<line x1="18.5" y1="51.5" x2="22" y2="51.5"/>
|
||||
<line x1="18.5" y1="45.75" x2="22" y2="45.75"/>
|
||||
<line x1="17" y1="40" x2="22" y2="40"/>
|
||||
</g>
|
||||
<text x="15" y="157" class="tank-num">0</text>
|
||||
<text x="15" y="134" class="tank-num">20</text>
|
||||
<text x="15" y="111" class="tank-num">40</text>
|
||||
<text x="15" y="88" class="tank-num">60</text>
|
||||
<text x="15" y="65" class="tank-num">80</text>
|
||||
<text x="15" y="42" class="tank-num">100</text>
|
||||
</g>
|
||||
|
||||
<!-- 뚜껑 (클릭하면 -90도 회전: ㅡ → ㅣ) -->
|
||||
<g class="tank-lid">
|
||||
<!-- 클릭 영역을 살짝 넓혀주는 투명 hit-box -->
|
||||
<rect x="25" y="8" width="68" height="32" fill="transparent"/>
|
||||
<!-- Top nozzle -->
|
||||
<rect x="51" y="10" width="16" height="4" rx="1" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
|
||||
<rect x="54" y="14" width="10" height="6" fill="url(#metal-grad)" stroke="var(--tank-stroke)" stroke-width="1"/>
|
||||
<!-- Dome cap -->
|
||||
<path d="M29 35 C29 22 43 18 59 18 C75 18 89 22 89 35 L89 39 L29 39 Z" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
|
||||
<rect x="27" y="35" width="64" height="5" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
|
||||
<line x1="29" y1="38" x2="89" y2="38" stroke="#333333" stroke-width="0.8"/>
|
||||
</g>
|
||||
|
||||
<!-- Tank glass body -->
|
||||
<rect x="29" y="39" width="60" height="115" fill="#081326" stroke="#ffffff" stroke-width="1"/>
|
||||
<rect x="31" y="40" width="56" height="113" fill="url(#glass-grad)" stroke="var(--tank-stroke)" stroke-width="1"/>
|
||||
|
||||
<!-- Liquid -->
|
||||
<rect id="tank-liquid" x="31" y="155" width="56" height="0" fill="var(--liquid-color)" clip-path="url(#tank-clip)"/>
|
||||
<ellipse id="tank-surface" cx="59" cy="155" rx="28" ry="2.2" fill="var(--liquid-color)" opacity="0.85" clip-path="url(#tank-clip)"/>
|
||||
|
||||
<!-- Glass highlight -->
|
||||
<path d="M35 43 L43 43 L38 146 L34 153 Z" fill="#ffffff" opacity="0.16"/>
|
||||
<rect x="82" y="42" width="3" height="111" fill="#ffffff" opacity="0.08"/>
|
||||
|
||||
<!-- Level readout -->
|
||||
<rect id="level-bg" x="40" y="83" width="38" height="18" rx="4" fill="#000000" stroke="#ffffff" stroke-width="1"/>
|
||||
<text id="level-text" x="59" y="92" class="tank-mono">0.0%</text>
|
||||
|
||||
<!-- Base -->
|
||||
<rect x="28" y="153" width="62" height="7" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
|
||||
<line x1="30" y1="154.5" x2="88" y2="154.5" stroke="#ffffff" stroke-width="0.6" opacity="0.65"/>
|
||||
|
||||
<!-- Outlet pipe + pump -->
|
||||
<rect x="55" y="160" width="8" height="12" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
|
||||
<circle cx="59" cy="166" r="8.5" fill="#081326" stroke="#ffffff" stroke-width="1"/>
|
||||
<circle cx="59" cy="166" r="6.6" fill="none" stroke="var(--tank-stroke)" stroke-width="1"/>
|
||||
<path d="M56 161.8 L56 170.2 L63.5 166 Z" fill="#ffffff"/>
|
||||
<rect x="56" y="173" width="7" height="7" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
|
||||
<rect x="53" y="178" width="13" height="2" fill="url(#metal-grad)" stroke="#ffffff" stroke-width="1"/>
|
||||
</svg>`;
|
||||
|
||||
//==============================================================
|
||||
// 탱크 SVG 컴포넌트 — 재사용 가능
|
||||
//==============================================================
|
||||
class TankGauge {
|
||||
/**
|
||||
* opts:
|
||||
* id 고유 식별자 (SVG 내부 id 충돌 방지용 prefix)
|
||||
* label 표시 라벨 (HCL, CuCl2 등)
|
||||
* capacityText "10m3" 같은 부가 텍스트 (없으면 표시 안함)
|
||||
* color 액체 색상 (CSS color)
|
||||
* level 초기 레벨 (0~100)
|
||||
* width/height SVG 크기 (생략시 110 x 180)
|
||||
*/
|
||||
constructor(opts) {
|
||||
Object.assign(this, opts);
|
||||
this.level = opts.level ?? 0;
|
||||
this.element = this._render();
|
||||
}
|
||||
|
||||
_render() {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.cssText = 'display:flex;flex-direction:column;align-items:center;width:100%;';
|
||||
|
||||
// 라벨
|
||||
const labelEl = document.createElement('div');
|
||||
labelEl.style.cssText = 'font-size:13px;font-weight:600;color:#fff;margin-bottom:2px;';
|
||||
labelEl.innerHTML = `${this.label}${this.capacityText ? ` <span style="color:#8af;font-size:11px;">(${this.capacityText})</span>` : ''}`;
|
||||
wrapper.appendChild(labelEl);
|
||||
|
||||
// SVG: id 와 url(#) 모두 instance prefix 치환
|
||||
const id = this.id;
|
||||
const svgHtml = TANK_SVG_TEMPLATE
|
||||
.replace(/id="/g, `id="${id}-`)
|
||||
.replace(/url\(#/g, `url(#${id}-`);
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = svgHtml;
|
||||
const svgEl = tmp.firstElementChild;
|
||||
|
||||
// 크기 조절 (HNO3 sub 탱크는 작게)
|
||||
svgEl.setAttribute('width', this.width ?? 110);
|
||||
svgEl.setAttribute('height', this.height ?? 180);
|
||||
|
||||
// CSS 변수로 색상 주입 — 인스턴스별로 액체/금속 색 분리
|
||||
svgEl.style.setProperty('--liquid-color', this.color);
|
||||
svgEl.style.setProperty('--tank-stroke', '#aaaaaa');
|
||||
svgEl.style.setProperty('--metal-light', '#dddddd');
|
||||
svgEl.style.setProperty('--metal-dark', '#666666');
|
||||
svgEl.style.background = '#050a18';
|
||||
svgEl.style.display = 'block';
|
||||
|
||||
// 뚜껑 클릭 → open/close 토글
|
||||
const lid = svgEl.querySelector('.tank-lid');
|
||||
if (lid) {
|
||||
lid.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
lid.classList.toggle('open');
|
||||
});
|
||||
}
|
||||
|
||||
wrapper.appendChild(svgEl);
|
||||
|
||||
// 막대 게이지 (탱크 아래)
|
||||
const gauge = document.createElement('div');
|
||||
gauge.className = 'gauge-row';
|
||||
gauge.innerHTML = `
|
||||
<div class="gauge-label">${this.label} SUPPLY LINES</div>
|
||||
<div class="gauge-bars" id="bars-${id}"></div>
|
||||
`;
|
||||
wrapper.appendChild(gauge);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 동적 레벨 변경 — SVG 내부 4개 element 갱신:
|
||||
* tank-liquid (rect) : y, height
|
||||
* tank-surface (ellipse): cy
|
||||
* level-text (text) : textContent
|
||||
* level-bg (rect) : fill (30% 미만 빨강 / 90% 초과 녹색 / 그 외 검정)
|
||||
*/
|
||||
setLevel(pct) {
|
||||
pct = Math.max(0, Math.min(100, pct));
|
||||
this.level = pct;
|
||||
const id = this.id;
|
||||
const liquid = this.element.querySelector(`#${id}-tank-liquid`);
|
||||
const surface = this.element.querySelector(`#${id}-tank-surface`);
|
||||
const pctText = this.element.querySelector(`#${id}-level-text`);
|
||||
const bg = this.element.querySelector(`#${id}-level-bg`);
|
||||
if (!liquid) return;
|
||||
|
||||
// 받은 SVG 좌표: 액체 영역 y=40 (가득) ~ y=155 (바닥), 높이 115
|
||||
const fullH = 115;
|
||||
const baseY = 155;
|
||||
const h = (pct / 100) * fullH;
|
||||
const y = baseY - h;
|
||||
liquid.setAttribute('y', y);
|
||||
liquid.setAttribute('height', h);
|
||||
surface.setAttribute('cy', y);
|
||||
pctText.textContent = pct.toFixed(1) + '%';
|
||||
|
||||
if (pct < 30) bg.setAttribute('fill', '#a02020');
|
||||
else if (pct > 90) bg.setAttribute('fill', '#206020');
|
||||
else bg.setAttribute('fill', '#000000');
|
||||
}
|
||||
|
||||
mount(parentSelector) {
|
||||
const parent = document.querySelector(parentSelector);
|
||||
if (parent) parent.appendChild(this.element);
|
||||
}
|
||||
}
|
||||
|
||||
//==============================================================
|
||||
// 막대 게이지 — 14칸 (녹/주/꺼짐 랜덤)
|
||||
//==============================================================
|
||||
function renderBars(id, count = 14) {
|
||||
const host = document.getElementById(`bars-${id}`);
|
||||
if (!host) return;
|
||||
host.innerHTML = '';
|
||||
for (let i = 0; i < count; i++) {
|
||||
const bar = document.createElement('div');
|
||||
const r = Math.random();
|
||||
bar.style.background = r < 0.55 ? '#3aa848' : r < 0.85 ? '#e88728' : '#1a2f50';
|
||||
host.appendChild(bar);
|
||||
}
|
||||
}
|
||||
|
||||
//==============================================================
|
||||
// 탱크 6 + 3 = 9 인스턴스 생성
|
||||
//==============================================================
|
||||
const tanks = [
|
||||
new TankGauge({ id: 'HCL', label: 'HCL', capacityText: '10m3', color: '#ff8a3a', level: 56.6 }),
|
||||
new TankGauge({ id: 'CuCl2', label: 'CuCl2', capacityText: '10m3', color: '#3acc4a', level: 73.7 }),
|
||||
new TankGauge({ id: 'OXA', label: 'OXA', capacityText: '10m3', color: '#5ac8e8', level: 44.5 }),
|
||||
new TankGauge({ id: 'HNO3', label: 'HNO3', capacityText: '', color: '#e040a8', level: 42.7, width: 80, height: 130 }),
|
||||
new TankGauge({ id: 'AU', label: 'AU PLATING', capacityText: '', color: '#888888', level: 0, width: 80, height: 130 }),
|
||||
new TankGauge({ id: 'WHNO3', label: 'WASTE HNO3', capacityText: '', color: '#f6a8c8', level: 47.6, width: 80, height: 130 }),
|
||||
new TankGauge({ id: 'H2SO4', label: 'H2SO4', capacityText: '5m3', color: '#e0488a', level: 72.9 }),
|
||||
new TankGauge({ id: 'H2O2', label: 'H2O2', capacityText: '5m3', color: '#f8c8d8', level: 84.1 }),
|
||||
];
|
||||
|
||||
tanks.forEach(t => {
|
||||
t.mount(`#cell-${t.id}`);
|
||||
t.setLevel(t.level);
|
||||
renderBars(t.id);
|
||||
});
|
||||
|
||||
//==============================================================
|
||||
// 시뮬레이션 — 1초마다 탱크 레벨 ±0.3% 변동, 막대 2초마다 갱신
|
||||
//==============================================================
|
||||
setInterval(() => {
|
||||
tanks.forEach(t => {
|
||||
if (t.id === 'AU') return; // AU PLATING 은 빈 탱크 유지
|
||||
const delta = (Math.random() - 0.5) * 0.6;
|
||||
t.setLevel(t.level + delta);
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
setInterval(() => {
|
||||
tanks.forEach(t => renderBars(t.id));
|
||||
}, 2000);
|
||||
|
||||
//==============================================================
|
||||
// 시계
|
||||
//==============================================================
|
||||
function tick() {
|
||||
const d = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
const date = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()}`;
|
||||
document.getElementById('clock').textContent = `${time} ${date}`;
|
||||
}
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
|
||||
//==============================================================
|
||||
// 데이터 테이블 시뮬레이션 — 8행 유지, 5초마다 새 행 추가
|
||||
//==============================================================
|
||||
const tbody = document.getElementById('dataBody');
|
||||
const totals = { HCL: 1258.4, CuCl2: 1523.1, OXA: 1024.8, HNO3: 856.3 };
|
||||
|
||||
function addRow() {
|
||||
const d = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const t = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
|
||||
const flows = {
|
||||
HCL: 18 + Math.random() * 1.5,
|
||||
CuCl2: 22 + Math.random() * 1.0,
|
||||
OXA: 15 + Math.random() * 0.8,
|
||||
HNO3: 12.5 + Math.random() * 0.6,
|
||||
};
|
||||
Object.keys(flows).forEach(k => totals[k] += flows[k]);
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
tr.classList.add('fresh');
|
||||
tr.innerHTML = `
|
||||
<td>${t}</td>
|
||||
<td>${flows.HCL.toFixed(1)}</td><td>${totals.HCL.toFixed(1)}</td>
|
||||
<td>${flows.CuCl2.toFixed(1)}</td><td>${totals.CuCl2.toFixed(1)}</td>
|
||||
<td>${flows.OXA.toFixed(1)}</td><td>${totals.OXA.toFixed(1)}</td>
|
||||
<td>${flows.HNO3.toFixed(1)}</td><td>${totals.HNO3.toFixed(1)}</td>
|
||||
`;
|
||||
tbody.insertBefore(tr, tbody.firstChild);
|
||||
while (tbody.children.length > 8) tbody.removeChild(tbody.lastChild);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 8; i++) addRow();
|
||||
setInterval(addRow, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user