import {NetworkRenderer} from './renderer.js'; import {layerOf} from './geometry.js'; export const app={network:null,renderer:null,selected:null,facilities:[]}; const $=s=>document.querySelector(s),$$=s=>[...document.querySelectorAll(s)]; const labels={junction:'맨홀',outfall:'방류구',storage:'저류시설',conduit:'관로',pump:'펌프',weir:'위어'}; const fmt=(n,d=2)=>n==null?'확인 불가':Number(n).toLocaleString('ko-KR',{minimumFractionDigits:d,maximumFractionDigits:d}); const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const unit=(n,d=2)=>n==null?'확인 불가':`${fmt(n,d)} m`; const row=(name,value)=>`
시설을 클릭하거나 ID를 검색하면
단면, 표고, 연결 정보를 확인할 수 있습니다.
좌표 없는 방류구·관로와 평면 길이 0인 관로는 임의로 배치하지 않았습니다.
${section('모델을 읽는 기준',`평면 좌표계와 단위는 미확인입니다.
XY 1단위=1m로 가정한 관망입니다.
맨홀 내경 1.2m는 표현용 가정입니다. 상단은 바닥 표고+최대 깊이이며 측량 지반고가 아닙니다.
원형·사각관은 입력 내부 단면을 표면으로 표시합니다. 벽 두께와 실제 시공 선형은 복원하지 않았습니다.
`)}`; $('#inspect-missing').onclick=()=>openAudit('placement'); } function drawProfile(f){ const canvas=$('#profile');if(!canvas)return;const c=canvas.getContext('2d'),dpr=Math.min(devicePixelRatio||1,2),w=240,h=150;canvas.width=w*dpr;canvas.height=h*dpr;c.scale(dpr,dpr);c.clearRect(0,0,w,h); const a=app.network.nodes.find(n=>n.id===f.fromNode),b=app.network.nodes.find(n=>n.id===f.toNode),z1=f.invertStart,z2=f.invertEnd,diam=f.section.height; const low=Math.min(a.invert,b.invert,z1,z2)-.25,high=Math.max(a.top??a.invert+1,b.top??b.invert+1,z1+diam,z2+diam)+.4,py=z=>117-(z-low)/(high-low)*91; const x1=36,x2=204; c.font='9px system-ui';c.fillStyle='#648263';c.textAlign='center';c.fillText(f.fromNode,x1,13);c.fillText(f.toNode,x2,13); for(const [n,x] of [[a,x1],[b,x2]]){const top=n.top??n.invert+1;c.fillStyle='#dce8d9';c.fillRect(x-10,py(top),20,py(n.invert)-py(top));c.strokeStyle='#83a17d';c.strokeRect(x-10,py(top),20,py(n.invert)-py(top));} c.fillStyle='#61a7a24d';c.strokeStyle='#498c84';c.lineWidth=1.4;c.beginPath();c.moveTo(x1,py(z1));c.lineTo(x2,py(z2));c.lineTo(x2,py(z2+diam));c.lineTo(x1,py(z1+diam));c.closePath();c.fill();c.stroke(); c.setLineDash([3,3]);c.strokeStyle='#c9d6c3';c.beginPath();c.moveTo(16,126);c.lineTo(224,126);c.stroke();c.setLineDash([]); c.fillStyle='#587455';c.textAlign='left';c.fillText(`관저 ${fmt(z1,3)}`,13,142);c.textAlign='right';c.fillText(`${fmt(z2,3)} m`,227,142); } export function selectFacility(f){ if(!f)return;app.selected=f;app.renderer.set({selected:f.pickId});$('#view-name').textContent=f.id; let html=`${labels[f.type]}${f.xy===null?'좌표가 없어 위치를 배치하지 않았습니다.':f.id==='UHR1-01411'?'양 끝의 평면 좌표가 같아 관로 형상을 생략했습니다.':'연결 노드 좌표 또는 단면이 없어 형상을 배치하지 않았습니다.'}
`; if(hidden)html+='이 시설의 표시 레이어가 꺼져 있습니다. 시설로 이동하면 해당 레이어를 켭니다.
'; if(['junction','storage','outfall'].includes(f.type)){ html+=section('위치와 표고',props([row('원본 X / Y',coord(f)),row('바닥 표고',unit(f.invert,3)),...(f.maxDepth!=null?[row('최대 깊이',unit(f.maxDepth,3)),row('모델상 상단',unit(f.top,3))]:[]),sourceRow(f)])); if(f.type==='junction')html+='내경 1.2m의 표현용 맨홀입니다. 벽 두께·뚜껑 규격은 미상이며 상단은 측량 지반고가 아닙니다.
'; if(f.type==='outfall')html+=section('방류 경계 조건',props([row('유형',esc(f.boundary)),row('입력 수위 / 시계열',esc(f.stage??'지정값 없음')),row('역류 방지문',esc(f.flapGate))]))+'입력 경계 조건을 표시합니다. 계산된 수위나 유량이 아닙니다. 방류구 높이·크기 기호는 표현용입니다.
'; if(f.type==='storage')html+=section('저류 모델',props([row('형상 방식',esc(f.storageShape)),row('면적–깊이 곡선',esc(f.storageCurve))]))+'높이는 입력 최대 깊이입니다. 평면 모양과 크기는 위치를 구분하는 기호이며 실제 저류조 치수가 아닙니다.
'; html+=section(`연결 시설 · ${f.connections.length}`,`${esc(f.fromNode)} → ${esc(f.toNode)}
INP 연결 순서이며 실제 유동 방향이 아닙니다.
입력 길이와 좌표 거리의 차이가 5%를 넘습니다. 입력 길이에 맞춰 노드 위치를 옮기지 않았습니다.
'; html+='좌표 거리와 차이는 XY 1단위=1m 가정입니다. 경사=(시작 관저고−끝 관저고)/각 길이. 양수는 입력 방향으로 낮아짐을 뜻하며 실제 흐름은 미해석입니다.
'; html+=section('관저고와 연결 높이',props([row('시작 끝 오프셋',unit(f.inletOffset,3)),row('도착 끝 오프셋',unit(f.outletOffset,3)),row('시작 관저고',unit(f.invertStart,3)),row('도착 관저고',unit(f.invertEnd,3)),row('시작 중심 표고',unit(f.centerStart,3)),row('도착 중심 표고',unit(f.centerEnd,3)),row('오프셋 해석',esc(app.network.meta.offsetMode))])); if(xs&&f.invertStart!=null&&f.invertEnd!=null)html+='원본 표고 연결 모식도 · 가로 길이는 축척 아님
상단=바닥 표고+최대 깊이 (측량 지반고 아님)
내부 단면 표면만 표시하며 관 벽 두께는 미상입니다. 이 형상은 실제 시공 선형을 복원한 것이 아닙니다.
'; }else if(f.type==='pump'){ html+=section('펌프 입력',props([row('펌프 곡선',esc(f.pumpCurve)),row('입력 초기 상태',esc(f.initialStatus)),row('기동 깊이',unit(f.startupDepth,3)),row('정지 깊이',unit(f.shutoffDepth,3)),sourceRow(f)])); html+='기동·정지 깊이는 입력 제어값입니다. 펌프 기호 및 연결선은 모식 표현이며 병렬 펌프는 선택을 위해 기호를 옆으로 구분했습니다. 실제 배관고·펌프 외형을 뜻하지 않습니다.
'; }else{ html+=section('위어 입력',props([row('위어 형식',esc(f.weirType)),row('마루 오프셋',unit(f.crestOffset,3)),row('마루 표고',unit(f.crestElevation,3)),row('유량계수',fmt(f.dischargeCoefficient,3)),row('입력 단면',esc(f.section?.shape)),row('입력 폭 / 높이',f.section?`${fmt(f.section.width,3)} / ${fmt(f.section.height,3)} m`:'단면 누락'),sourceRow(f)])); html+='위어는 일반 관로와 구분한 위치·연결 기호입니다. 폭과 마루 형상의 실제 시공 상세는 재현하지 않습니다.
'; } } $('#details').innerHTML=html;$('#focus-selected').onclick=()=>focusSelected(); $$('#details [data-select]').forEach(b=>b.onclick=()=>selectFacility(getFacility(b.dataset.select))); drawProfile(f); } function focusSelected(){ const f=app.selected;if(!f)return;if(!isPlaced(f)){toast('좌표 또는 형상 확인이 필요해 이동할 수 없습니다.');return;} const layer=layerOf(f);$(`[data-layer="${layer}"]`).checked=true;app.renderer.setLayer(layer,true);app.renderer.focus(f);toast(`${f.id} · 제원은 입력 원값입니다.`); } function updateSearch(){ const q=$('#search').value.trim().toLowerCase();const container=$('#search-results'); if(!q){container.replaceChildren();$('#search-status').textContent='';return;} const result=app.facilities.filter(f=>f.id.toLowerCase().includes(q)).sort((a,b)=>(b.id.toLowerCase()===q)-(a.id.toLowerCase()===q)||a.id.localeCompare(b.id,undefined,{numeric:true})); $('#search-status').textContent=result.length?`${result.length}개 결과${result.length>40?' · 앞 40개 표시':''}`:'일치하는 시설이 없습니다.'; container.innerHTML=result.slice(0,40).map(f=>``).join(''); container.querySelectorAll('button').forEach(b=>b.onclick=()=>selectFacility(app.facilities.find(f=>f.pickId===Number(b.dataset.pick)))); } function updateScales(){ const height=Number($('#height-scale').value),size=Number($('#size-scale').value);app.renderer.set({height,size,section:$('#section-mode').checked}); $('#height-output').value=`×${height}`;$('#size-output').value=`×${size}`;$('#height-badge').textContent=`높이 ×${height}`;$('#size-badge').textContent=`단면·맨홀 폭 ×${size}`;$('#section-badge').hidden=!$('#section-mode').checked; } function syncAuditHighlight(){ const enabled=$('#audit-highlight').checked,reduced=matchMedia('(prefers-reduced-motion: reduce)').matches; const steady=$('#audit-steady').checked||reduced; $('#audit-steady').disabled=!enabled||reduced; $('#audit-highlight-note').textContent=reduced?'동작 줄이기 설정에 따라 고정 강조합니다. 오류 확정이 아닌 점검 필요 표시입니다.':steady?'고정 빨간색으로 강조합니다. 오류 확정이 아닌 점검 필요 표시입니다.':'빨간색 1초 · 원래 색 1초 반복. 오류 확정이 아닌 점검 필요 표시입니다.'; app.renderer.setAuditHighlight(enabled,steady); } function reset(){ $('#audit-highlight').checked=false;$('#audit-steady').checked=false;syncAuditHighlight(); $('#height-scale').value=3;$('#size-scale').value=3;$('#section-mode').checked=false;$$('[data-layer]').forEach(b=>{b.checked=true;app.renderer.setLayer(b.dataset.layer,true);});updateScales();app.renderer.set({selected:0});app.selected=null;$('#search').value='';updateSearch();clearDetails();$('#view-name').textContent='전체 관망';app.renderer.setView('iso');app.renderer.fit();toast('표시·배율·시점을 초기화했습니다.'); } function frame(renderer,error){ if(error==='contextlost'){fatal('그래픽 연결이 중단되었습니다. 페이지를 새로고침해 주세요.');return;} const visible=app.facilities.filter(f=>renderer.visible(f));const nodes=visible.filter(f=>f.xy).length,conduits=visible.filter(f=>f.type==='conduit').length,other=visible.filter(f=>['pump','weir'].includes(f.type)).length; $('#visible-count').textContent=`표시 노드 ${nodes} · 관로 ${conduits} · 펌프·위어 ${other}`; const names={iso:'입체 보기',plan:'평면 보기',side:'측면 보기',custom:'사용자 시점'};$('#view-badge').textContent=names[renderer.view];$$('[data-view]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.view===renderer.view))); $('#render-status').textContent=`로컬 모델 · 표시 시설 ${visible.length}`; $('#model-status').textContent=`수리 제원 m · 원점 X ${fmt(app.network.meta.origin[0],1)} / Y ${fmt(app.network.meta.origin[1],1)}`; } function openAudit(filter='placement'){$('#audit-filter').value=filter;updateAudit();$('#audit-dialog').showModal();} function updateAudit(){ const filter=$('#audit-filter').value,issues=app.network.issues.filter(i=>filter==='all'||(filter==='placement'?['missing_coordinate','unplaced_link','coincident_endpoints'].includes(i.code):i.code===filter)); $('#audit-list').innerHTML=issues.length?issues.map(i=>`이 항목에 점검 사항이 없습니다.
'; $$('#audit-list [data-select]').forEach(b=>b.onclick=()=>{selectFacility(getFacility(b.dataset.select));$('#audit-dialog').close();}); } function fatal(message){$('#loading').hidden=true;$('#fatal-error').hidden=false;$('#fatal-error').textContent=message;$('#render-status').textContent='실행 확인 필요';} async function main(){ try{ const response=await fetch('data/network.json',{cache:'no-store'});if(!response.ok)throw Error('관망 데이터를 읽을 수 없습니다. web/data/network.json 파일이 포함되어 있는지 확인해 주세요.'); app.network=await response.json();const n=app.network,s=n.summary; app.facilities=[...n.nodes,...n.links].map((f,i)=>({...f,pickId:i+1})); app.renderer=new NetworkRenderer($('#scene'),n,app.facilities,selectFacility,frame); const unplaced=app.facilities.filter(f=>!isPlaced(f)); $('#audit-highlight-count').textContent=`표시 가능한 점검 관로 ${app.renderer.auditPickIds.size}개`; $('#audit-unplaced').textContent=`위치 표시 불가 ${unplaced.length}개 · 사유 보기 ↗`; $('#audit-unplaced').onclick=()=>openAudit('placement'); $('#audit-highlight').onchange=syncAuditHighlight;$('#audit-steady').onchange=syncAuditHighlight; matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change',syncAuditHighlight);syncAuditHighlight(); $('#node-count').textContent=s.counts.junction;$('#pipe-count').textContent=s.counts.conduit; for(const [layer,count] of Object.entries({junction:s.counts.junction,conduit:s.counts.conduit,outfall:s.counts.outfall,other:s.counts.storage+s.counts.pump+s.counts.weir}))$(`#layer-${layer}-count`).textContent=count; $('#circle-count').textContent=s.shapes.CIRCULAR;$('#rect-count').textContent=s.shapes.RECT_CLOSED;$('#source-name').textContent=n.meta.sourceName; $('#audit-count').textContent=`배치 점검 ${n.issues.filter(i=>['missing_coordinate','unplaced_link','coincident_endpoints'].includes(i.code)).length}`; $('#audit-summary').innerHTML=`CMS · 오프셋 ${esc(n.meta.offsetMode)} · 수리 제원 m
평면 좌표계 미확인 / MAP Units=${esc(n.meta.mapUnits)} / XY 단위 가정