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)=>`
${esc(name)}
${value}
`; const props=rows=>`
${rows.join('')}
`; const section=(name,content)=>`

${esc(name)}

${content}
`; let toastTimer; function toast(text){$('#toast').textContent=text;$('#toast').classList.add('visible');clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('#toast').classList.remove('visible'),3500);} function coord(n){return n.xy?`${fmt(n.xy[0],3)}
${fmt(n.xy[1],3)}`:'좌표 없음 · 미배치';} function isPlaced(f){return f.xy!=null||f.renderable;} function getFacility(id){return app.facilities.find(f=>f.id===id);} function linked(id){return ``;} function sourceRow(f){return row('원본 위치',`[${f.type==='junction'?'JUNCTIONS':f.type==='outfall'?'OUTFALLS':f.type==='storage'?'STORAGE':f.type==='conduit'?'CONDUITS':f.type==='pump'?'PUMPS':'WEIRS'}]
${f.line}행`);} function clearDetails(){ const n=app.network,s=n.summary; $('#details').innerHTML=`

연결을 선택해 보세요.

시설을 클릭하거나 ID를 검색하면
단면, 표고, 연결 정보를 확인할 수 있습니다.

${[['입력 노드',n.nodes.length],['배치 노드',s.coordinateCount],['배치 관로',s.renderedConduits],['방류구',s.counts.outfall],['저류시설',s.counts.storage],['펌프 / 위어',`${s.counts.pump} / ${s.counts.weir}`]].map(([a,b])=>`
${esc(a)}${esc(b)}
`).join('')}

좌표 없는 방류구·관로와 평면 길이 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]}

${esc(f.id)}

`; const placed=isPlaced(f),hidden=!app.renderer.state.layers[layerOf(f)]; html+=``; if(!placed)html+=`

${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}`,`
${f.connections.map(linked).join('')}
`); }else{ html+=section('입력 연결 방향',`
${linked(f.fromNode)}${linked(f.toNode)}

${esc(f.fromNode)} → ${esc(f.toNode)}
INP 연결 순서이며 실제 유동 방향이 아닙니다.

`); if(f.type==='conduit'){ const xs=f.section; html+=section('단면 제원 · 입력 원값',props([row('단면',esc(xs?.shape??'누락')), ...(xs?.shape==='CIRCULAR'?[row('안지름',`Ø ${unit(xs.diameter,3)}`)]:xs?[row('폭 × 높이',`${fmt(xs.width,3)} × ${fmt(xs.height,3)} m`)]:[]),row('병렬 관 수',esc(xs?.barrels??'확인 불가')),row('조도계수 Manning n',fmt(f.roughness,3))])); html+=section('길이와 경사',props([row('입력 길이',unit(f.length,3)),row('좌표상 평면 거리',f.planDistance!=null?`${fmt(f.planDistance,3)} 가정 m`:'좌표 누락'),row('거리 − 입력 길이',f.lengthDelta!=null?`${f.lengthDelta>=0?'+':''}${unit(f.lengthDelta,3)}`:'확인 불가'),row('상대 차이',f.lengthDifferenceRatio!=null?`${fmt(f.lengthDifferenceRatio*100,2)} %`:'확인 불가'),row('입력 길이 기준 경사',f.slopeInput!=null?`${fmt(f.slopeInput*100,3)} %`:'확인 불가'),row('좌표 거리 기준 경사',f.slopePlan!=null?`${fmt(f.slopePlan*100,3)} %`:'계산 불가')])); if(f.lengthDifferenceRatio>.05)html+='

입력 길이와 좌표 거리의 차이가 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+='

원본 표고 연결 모식도 · 가로 길이는 축척 아님
상단=바닥 표고+최대 깊이 (측량 지반고 아님)

'; html+=section('형상 구성',props([row('중간 꺾임',`${f.vertices.length}개`),row('평면 연결',f.vertices.length?'입력 중간점 연결':'양 끝 좌표 직선 연결'),sourceRow(f)])); 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 reset(){ $('#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=>`
${esc(i.message)}${i.ids[0]&&getFacility(i.ids[0])?``:''}
`).join(''):'

이 항목에 점검 사항이 없습니다.

'; $$('#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); $('#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=`
${[['노드 / 관로',`${n.nodes.length} / ${s.counts.conduit}`],['원형 / 사각관',`${s.shapes.CIRCULAR} / ${s.shapes.RECT_CLOSED}`],['단면·연결 누락',`${(s.issues.missing_section??0)+(s.issues.unknown_endpoint??0)}건`],['길이 차이 > 5%',`${s.lengthOver5} / ${s.lengthCompared}`],['중간 꺾임 좌표',`${s.vertexCount}개`],['연결 성분',`${s.componentSizes.length}개`]].map(([a,b])=>`
${esc(a)}${esc(b)}
`).join('')}

CMS · 오프셋 ${esc(n.meta.offsetMode)} · 수리 제원 m
평면 좌표계 미확인 / MAP Units=${esc(n.meta.mapUnits)} / XY 단위 가정

`; clearDetails();$('#search').oninput=updateSearch;$('#search').onkeydown=e=>{if(e.key==='Enter')$('#search-results button')?.click();}; $$('[data-layer]').forEach(b=>b.onchange=()=>app.renderer.setLayer(b.dataset.layer,b.checked)); $('#show-all').onclick=()=>$$('[data-layer]').forEach(b=>{b.checked=true;app.renderer.setLayer(b.dataset.layer,true);}); for(const id of ['height-scale','size-scale','section-mode'])$(`#${id}`).addEventListener('input',updateScales); $('#actual-scale').onclick=()=>{$('#height-scale').value=1;$('#size-scale').value=1;updateScales();toast('표고·단면 배율 ×1. XY 단위와 맨홀 규격은 여전히 가정입니다.');}; $('#fit').onclick=()=>{app.renderer.fit();toast('표시 중인 관망을 화면에 맞췄습니다.');};$('#reset').onclick=reset; $$('[data-view]').forEach(b=>b.onclick=()=>app.renderer.setView(b.dataset.view)); $('#open-audit').onclick=()=>openAudit();$('#audit-filter').onchange=updateAudit;$('#open-help').onclick=()=>$('#help-dialog').showModal(); $$('.close-dialog').forEach(b=>b.onclick=()=>b.closest('dialog').close()); document.addEventListener('keydown',e=>{if(e.target.matches('input,select,textarea,button')||$('dialog[open]'))return;const k=e.key.toLowerCase();if(k==='f')app.renderer.fit();if(k==='r')reset();if(['1','2','3'].includes(k))app.renderer.setView(['iso','plan','side'][Number(k)-1]);}); $('#loading').hidden=true;app.renderer.request(); }catch(error){console.error(error);fatal(`${error.message} 이 뷰어는 file://가 아닌 로컬 HTTP 서버에서 실행해야 합니다.`);} } main();