feat: 06-17 신규 작업본 반영 (개발사양서/기능검토/승인원/Source 등 추가)

.claude/ 제외(.gitignore 추가). 기존 초기커밋(5a96a69) 위에 신규·수정·이동분 커밋.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 07:54:58 +09:00
parent 5a96a696b1
commit 096111e983
529 changed files with 12439 additions and 1166 deletions
@@ -171,6 +171,10 @@
<div class="modal-h"><span>공기질 히스테리시스 (ECO / NORMAL / TURBO)</span>
<button class="btn" onclick="closeModal()">닫기</button></div>
<div class="ctrl-row"><span class="lbl">프리셋</span><span id="cPresets"></span></div>
<div class="ctrl-row" style="gap:8px;margin-top:2px">
<button class="btn" onclick="hystReadErv()">읽어오기</button>
<button class="btn" onclick="hystPreset()">프리셋(기본값)</button>
</div>
<div style="font-weight:700;font-size:13px;margin:6px 0 2px">데드밴드(하강)</div>
<div id="hystGrid"></div>
<button class="btn" style="margin-top:10px" onclick="applyHyst()">데드밴드 변경</button>
@@ -182,6 +186,11 @@
<div class="modal-h"><span>풍량 VSP 제어 · 상태 (SA 급기 / EA 배기)</span>
<button class="btn" onclick="closeModal()">닫기</button></div>
<div class="vsp-grid" id="vspGrid"></div>
<div style="margin-top:10px;display:flex;gap:8px">
<button class="btn" onclick="vspReadErv()">읽어오기</button>
<button class="btn" onclick="vspPreset()">프리셋(기본값)</button>
<button class="btn" onclick="vspApply()">변경</button>
</div>
</div>
<div class="modal modal-wide" id="modalGraph" style="display:none">
<div class="modal-h"><span>로그 그래프 (가로 시간 · 세로 댐퍼/센서/모드)</span>
@@ -232,6 +241,10 @@ const THR_DEMO = [
{co2:[1000,1300,1600,2000],pm25:[20,38,60,86],pm10:[40,86,126,173],voc:[171,195,308,438]},
{co2:[800,1100,1400,1700],pm25:[14,29,49,69],pm10:[28,66,102,138],voc:[120,150,250,350]},
{co2:[700,1000,1300,1600],pm25:[12,23,38,52],pm10:[24,53,78,104],voc:[103,120,192,263]}];
// ===== 사양 기본값 (모달 '프리셋' 버튼) : VSP=개발사양서 p.12(휴벤 ECO2/좌타입=펌웨어) / 데드밴드·임계=p.10 =====
const VSP_DEFAULT = [[56,57],[63,63],[70,70],[86,85],[67,75],[65,0],[72,0],[78,0],[80,0]];
const DB_DEFAULT = [{pm25:2,pm10:5,voc:5,co2:50},{pm25:2,pm10:5,voc:5,co2:50},{pm25:2,pm10:5,voc:3,co2:30}];
const THR_DEFAULT = THR_DEMO.map(t=>({co2:[...t.co2],pm25:[...t.pm25],pm10:[...t.pm10],voc:[...t.voc]}));
const HIST=60;
let current="site01", demoOn=true, tick=0;
@@ -245,6 +258,12 @@ SITES.forEach((s,si)=>{ state[s]={
thr:THR_DEMO.map(t=>({co2:[...t.co2],pm25:[...t.pm25],pm10:[...t.pm10],voc:[...t.voc]})),
seed:si*100 }; });
// 모달 편집 버퍼 (폴링이 덮어쓰지 않음). null=ERV(state)값 표시. 읽어오기/프리셋/편집 시 채움, 변경/닫기 시 비움.
let bufVsp=null, bufHyst=null, bufThr=null;
function copyVsp(v){ return (v||[]).map(x=>({sa:x.sa,ea:x.ea})); }
function copyHyst(h){ return (h||[]).map(x=>({pm25:x.pm25,pm10:x.pm10,voc:x.voc,co2:x.co2})); }
function copyThr(t){ return (t||[]).map(x=>({co2:[...x.co2],pm25:[...x.pm25],pm10:[...x.pm10],voc:[...x.voc]})); }
// ===== 데모 생성기 =====
function genSensors(s,t){
const st=state[s]; st.online=true;
@@ -289,6 +308,7 @@ async function ctl(action,a={}){
}
}
function flash(msg){ const f=document.getElementById("footNote"); f.textContent=msg; f.style.color="var(--bad)"; setTimeout(setFoot,2500); }
function okMsg(msg){ const f=document.getElementById("footNote"); f.textContent=msg; f.style.color="var(--good)"; setTimeout(setFoot,2200); }
// ===== 제어 핸들러 =====
function togglePower(){ ctl("power",{value:state[current].g.power?0:1}); }
@@ -310,13 +330,18 @@ function setDamperEa(room){ if(state[current].g.run_mode===2)return; const cur=s
function setLed(room,val){ ctl("led",{room,value:parseInt(val)}); }
function setReserve(h){ ctl("reserve",{value:parseInt(h)}); }
function toggleReset(){ ctl("reset",{value:state[current].g.reset?0:1}); }
function setHyst(pi,field,val){ state[current].hyst[pi][field]=parseInt(val)||0; }
function applyHyst(){ const st=state[current];
for(let pi=0;pi<3;pi++){ const v=st.hyst[pi]; ctl("hyst",{preset:pi,pm25:v.pm25,pm10:v.pm10,voc:v.voc,co2:v.co2}); } }
function setThr(pi,poll,li,val){ state[current].thr[pi][poll][li]=parseInt(val)||0; }
function applyThr(){ const st=state[current];
for(let pi=0;pi<3;pi++) POLL.forEach((poll,pp)=>{ const v=st.thr[pi][poll];
ctl("hystthr",{preset:pi,pollutant:pp,l1:v[0],l2:v[1],l3:v[2],l4:v[3],_poll:poll}); }); }
function setHyst(pi,field,val){ if(!bufHyst)bufHyst=copyHyst(state[current].hyst); bufHyst[pi][field]=parseInt(val)||0; }
function applyHyst(){ const hb=bufHyst||state[current].hyst;
for(let pi=0;pi<3;pi++){ const v=hb[pi]; ctl("hyst",{preset:pi,pm25:v.pm25,pm10:v.pm10,voc:v.voc,co2:v.co2}); }
bufHyst=null; okMsg("데드밴드 전송 완료"); }
function setThr(pi,poll,li,val){ if(!bufThr)bufThr=copyThr(state[current].thr); bufThr[pi][poll][li]=parseInt(val)||0; }
function applyThr(){ const tb=bufThr||state[current].thr;
for(let pi=0;pi<3;pi++) POLL.forEach((poll,pp)=>{ const v=tb[pi][poll];
ctl("hystthr",{preset:pi,pollutant:pp,l1:v[0],l2:v[1],l3:v[2],l4:v[3],_poll:poll}); });
bufThr=null; okMsg("임계 전송 완료"); }
// 히스테리시스 읽어오기/프리셋 (편집은 '데드밴드 변경'/'임계 변경'으로 전송)
function hystReadErv(){ bufHyst=copyHyst(state[current].hyst); bufThr=copyThr(state[current].thr); renderHyst(); renderThr(); okMsg("ERV값 불러옴"); }
function hystPreset(){ bufHyst=DB_DEFAULT.map(h=>({...h})); bufThr=copyThr(THR_DEFAULT); renderHyst(); renderThr(); okMsg("사양 기본값 불러옴 — '변경'으로 전송"); }
// ===== 팝업 =====
function openModal(which){
@@ -324,18 +349,24 @@ function openModal(which){
document.getElementById("modalVsp").style.display = which==="vsp"?"block":"none";
document.getElementById("modalGraph").style.display = which==="graph"?"block":"none";
document.getElementById("modalBg").classList.add("show");
if(which==="vsp") bufVsp=copyVsp(state[current].vsp); // 열 때 ERV 현재값
if(which==="hyst"){ bufHyst=copyHyst(state[current].hyst); bufThr=copyThr(state[current].thr); }
renderControls(); renderHyst(); renderThr(); renderVsp();
if(which==="graph"){ renderSide(); loadGraph(); } // 열 때 현재 로드날짜로 갱신
}
function graphOpen(){ return document.getElementById("modalBg").classList.contains("show")
&& document.getElementById("modalGraph").style.display==="block"; }
function closeModal(){ document.getElementById("modalBg").classList.remove("show"); }
function closeModal(){ document.getElementById("modalBg").classList.remove("show"); bufVsp=bufHyst=bufThr=null; }
function closeModalBg(e){ if(e.target.id==="modalBg") closeModal(); }
function setVsp(i,field,val){
const v=state[current].vsp[i];
const sa=field==="sa"?(parseInt(val)||0):v.sa, ea=field==="ea"?(parseInt(val)||0):v.ea;
ctl("vsp",{group:VSP_GROUP[i],index:VSP_INDEX[i],sa,ea,_idx:i});
if(!bufVsp) bufVsp=copyVsp(state[current].vsp);
bufVsp[i][field]=parseInt(val)||0;
}
function vspReadErv(){ bufVsp=copyVsp(state[current].vsp); renderVsp(); okMsg("ERV값 불러옴"); }
function vspPreset(){ bufVsp=VSP_DEFAULT.map(([sa,ea])=>({sa,ea})); renderVsp(); okMsg("사양 기본값 불러옴 — '변경'으로 전송"); }
function vspApply(){ const b=bufVsp||state[current].vsp;
for(let i=0;i<9;i++) ctl("vsp",{group:VSP_GROUP[i],index:VSP_INDEX[i],sa:b[i].sa,ea:b[i].ea,_idx:i});
bufVsp=null; renderVsp(); okMsg("VSP 전송 완료"); }
// ===== 스마트수면 시간설정 (브라우저 스케줄, 현재 현장에 적용) =====
let _slLast=-1;
@@ -443,19 +474,19 @@ function renderRooms(){
function renderHyst(){
const grid=document.getElementById("hystGrid");
if(document.activeElement && grid.contains(document.activeElement)) return;
const st=state[current];
const hb=bufHyst||state[current].hyst;
let h='<div class="hyst-grid"><span class="hh"></span><span class="hh">PM2.5</span><span class="hh">PM10</span><span class="hh">VOC</span><span class="hh">CO2</span>';
HYST_PRESETS.forEach((name,pi)=>{ const v=(st.hyst&&st.hyst[pi])||{pm25:0,pm10:0,voc:0,co2:0};
HYST_PRESETS.forEach((name,pi)=>{ const v=(hb&&hb[pi])||{pm25:0,pm10:0,voc:0,co2:0};
h+=`<span class="pl">${name}</span>`+HYST_FIELDS.map(f=>`<input type="number" min="0" value="${v[f]}" onchange="setHyst(${pi},'${f}',this.value)">`).join(""); });
grid.innerHTML=h+"</div>";
}
function renderThr(){
const grid=document.getElementById("thrGrid");
if(document.activeElement && grid.contains(document.activeElement)) return;
const st=state[current];
const tb=bufThr||state[current].thr;
let h='<div class="thr-grid"><span class="hh"></span><span class="hh">L1</span><span class="hh">L2</span><span class="hh">L3</span><span class="hh">L4</span>';
HYST_PRESETS.forEach((name,pi)=>{
POLL.forEach(poll=>{ const v=(st.thr&&st.thr[pi]&&st.thr[pi][poll])||[0,0,0,0];
POLL.forEach(poll=>{ const v=(tb&&tb[pi]&&tb[pi][poll])||[0,0,0,0];
h+=`<span class="pl">${name}·${poll.toUpperCase()}</span>`+
[0,1,2,3].map(li=>`<input type="number" min="0" value="${v[li]}" onchange="setThr(${pi},'${poll}',${li},this.value)">`).join("");
});
@@ -465,8 +496,8 @@ function renderThr(){
function renderVsp(){
const grid=document.getElementById("vspGrid");
if(document.activeElement && grid.contains(document.activeElement)) return;
const st=state[current];
grid.innerHTML = VSP_LABELS.map((lab,i)=>{ const v=(st.vsp&&st.vsp[i])||{sa:0,ea:0};
const arr=bufVsp||state[current].vsp;
grid.innerHTML = VSP_LABELS.map((lab,i)=>{ const v=(arr&&arr[i])||{sa:0,ea:0};
return `<div class="vsp-row"><span class="vl">${lab}</span>`+
`<span class="u">SA</span><input type="number" min="0" value="${v.sa}" onchange="setVsp(${i},'sa',this.value)">`+
`<span class="u">EA</span><input type="number" min="0" value="${v.ea}" onchange="setVsp(${i},'ea',this.value)"></div>`;