feat: SCADA 데모 음성 인식 + 경고 버튼 디자인 통일
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:
2026-05-03 05:39:43 +09:00
parent 3a0ab10ee6
commit e70267f738
21 changed files with 2320 additions and 339 deletions
@@ -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>