Add standalone Hwacheon network viewer

This commit is contained in:
dekim2 committed 2026-10-27 15:18:46 +09:00
commit d64b820ae2
15 files changed
+748

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
# Python and local runtime output
__pycache__/
*.py[cod]
.venv/
venv/
*.log
*.pid
.local/
test-results/
playwright-report/
.pytest_cache/
.coverage
htmlcov/
# Credentials and machine-specific settings
.env
.env.*
!.env.example
*.pem
*.key
*.p12
*.pfx
*credentials*
*secrets*
.aws/
.ssh/
.codex/
.agents/
.vscode/
.idea/
# Source drawings are not part of this runtime submission.
# web/data/network.json MUST remain included: the viewer needs it.
drawings/
references/
*.inp
*.INP
*.dxf
*.DXF
*.pdf
*.PDF
# Temporary and platform files
*.tmp
*.temp
*.bak
*.swp
*~
.DS_Store
Thumbs.db
node_modules/
+40
View File
@@ -0,0 +1,40 @@
# 화천군 관망도
SWMM 입력 자료의 화천군 맨홀·우수관로를 WebGL로 탐색하는 웹사이트입니다. 기존 웹사이트에서 화천 화면만 분리했으며 검색, 시설 제원, 연결부 내부 보기, 레이어·배율·시점 조작을 제공합니다. 수리 해석은 수행하지 않습니다.
## 실행
Python 3.10 이상과 WebGL을 지원하는 최신 Chrome 또는 Edge가 필요합니다. Python 표준 라이브러리만 사용하며 별도 패키지 설치나 빌드가 없습니다.
```bash
cd /home/user/hwacheon-network-viewer
python3 start.py
```
브라우저에서 **http://127.0.0.1:8080/** 를 열면 화천 관망도가 바로 표시됩니다. 실행 터미널을 유지하고 종료할 때 `Ctrl+C`를 누릅니다. 포트가 사용 중이면 `python3 start.py --port 8081`로 실행합니다. 다른 위치에 복사한 경우 해당 프로젝트 폴더에서 실행하면 됩니다.
내부망 공유는 `python3 start.py --lan`으로 실행합니다. WSL 환경에서는 Windows 포트 전달·방화벽 설정이 별도로 필요합니다. 공유 모드에는 로그인이 없습니다. 글꼴·스크립트·데이터는 모두 프로젝트 안에서 읽습니다.
## 파일 구성
- `start.py`, `serve.py`: 실행 진입점과 `web/` 전용 HTTP 서버.
- `web/index.html`, CSS: 화천 화면과 공통 스타일.
- `web/app.js`: 데이터 로딩·검색·시설 정보·화면 제어.
- `web/renderer.js`, `geometry.js`, `math.js`: 기존 WebGL 렌더링·형상·좌표 계산.
- `web/data/network.json`: 실행에 필요한 변환된 화천 관망 데이터.
- `web/fonts/`: Pretendard 글꼴과 원문 라이선스.
## 데이터 출처와 이용 범위
기존 프로젝트의 제공 자료 `HC_A.inp`를 변환한 `network.json`을 그대로 포함합니다. 원본 SHA-256은 JSON의 `meta.sha256`에 기록되어 있으며 분리 당시 원본과 일치했습니다. 맨홀 805개, 방류구 9개, 저류시설 1개, 관로 843개, 펌프 3개, 위어 1개를 포함합니다. 원본 INP·도면·PDF와 변환 도구는 이번 실행용 제출 범위에서 제외했습니다.
**원본 프로젝트는 원자료와 생성 JSON을 Git 제외 대상으로 지정하고 있었습니다.** 실행 독립성을 위해 이 분리본은 JSON을 포함하도록 구성했으나, 시설 ID·좌표·제원 등 실제 자료의 회사 Gitea 제출 허용 여부와 저장소 공개 범위는 담당자 확인이 필요합니다. 원자료의 소유자·이용허락 문서는 확인되지 않았습니다.
별도 외부 JavaScript 라이브러리는 포함하지 않습니다. Pretendard Variable 1.3.9는 [해당 버전 공식 라이선스](https://github.com/orioncactus/pretendard/blob/v1.3.9/LICENSE)의 SIL OFL 1.1을 따르며 `web/fonts/LICENSE.txt`를 함께 제공합니다. 프로젝트 자체 코드의 라이선스와 회사 제출 범위는 미확정이므로 임의의 오픈소스 라이선스는 부여하지 않았습니다.
## 주요 미확정 사항
- 좌표계·XY 단위는 미확인으로, XY 1단위=1m를 가정합니다.
- 맨홀 내경 1.2m는 표현용 가정이며 실제 벽 두께·시공 선형·측량 지반고는 복원하지 않습니다.
- 좌표 누락·겹침, 입력 길이와 좌표 거리 차이, 입력 방향 기준 역경사 등이 남아 있습니다. 화면의 기존 데이터 점검 항목은 오류 확정이나 검증 완료를 뜻하지 않습니다.
- 표시·계산의 정확성과 브라우저별 동작에 대한 정식 검증은 별도 과제입니다. 개선·검증 계획은 향후 Gitea 이슈로 관리합니다.
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Serve the bundled Hwacheon viewer locally, or on the LAN with --lan."""
import argparse
from functools import partial
from http.server import SimpleHTTPRequestHandler,ThreadingHTTPServer
from pathlib import Path
import sys
ROOT=Path(__file__).resolve().parent
class Handler(SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header('Cache-Control','no-store')
self.send_header('X-Content-Type-Options','nosniff')
self.send_header('Referrer-Policy','no-referrer')
super().end_headers()
def list_directory(self,path):
self.send_error(403,'Directory listing disabled')
return None
def translate_path(self,path):
root=(ROOT/'web').resolve()
candidate=Path(super().translate_path(path)).resolve()
if not candidate.is_relative_to(root):return str(root/'__not_found__')
return str(candidate)
def parse_args(argv=None):
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--port',type=int,default=8080)
parser.add_argument('--lan',action='store_true',help='Internal LAN sharing: bind 0.0.0.0 instead of the default 127.0.0.1')
args=parser.parse_args(argv)
if not 1 <= args.port <= 65535:
parser.error('--port must be between 1 and 65535')
return args
def main(args=None):
if args is None:
args=parse_args()
if not (ROOT/'web/data/network.json').is_file():
print('web/data/network.json이 없습니다. 제출 파일 구성을 확인하세요.',file=sys.stderr)
return 1
handler=partial(Handler,directory=str(ROOT/'web'))
host='0.0.0.0' if args.lan else '127.0.0.1'
try:server=ThreadingHTTPServer((host,args.port),handler)
except OSError as e:
print(f'서버를 시작할 수 없습니다: {e}. --port 8081 등 다른 포트를 지정하세요.',file=sys.stderr)
return 1
print(f'화천군 관망도: http://127.0.0.1:{args.port}',flush=True)
if args.lan:
print(f'내부 공유 모드: {host}:{args.port} · Windows LAN 전달/방화벽 설정 필요 · 로그인 없음',flush=True)
else:
print('로컬 모드: 127.0.0.1에만 바인딩합니다.',flush=True)
print('종료: Ctrl+C',flush=True)
try:server.serve_forever()
except KeyboardInterrupt:pass
finally:server.server_close()
return 0
if __name__=='__main__':raise SystemExit(main())
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env python3
"""Start the standalone Hwacheon network viewer using its bundled data."""
from serve import main
if __name__ == '__main__':
raise SystemExit(main())
+130
View File
@@ -0,0 +1,130 @@
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const unit=(n,d=2)=>n==null?'확인 불가':`${fmt(n,d)} m`;
const row=(name,value)=>`<div><dt>${esc(name)}</dt><dd>${value}</dd></div>`;
const props=rows=>`<dl class="properties">${rows.join('')}</dl>`;
const section=(name,content)=>`<section class="detail-section"><h3>${esc(name)}</h3>${content}</section>`;
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)}<br>${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 `<button class="connection-link" data-select="${esc(id)}"><span>${esc(id)}</span><small>제원 보기 ↗</small></button>`;}
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'}]<br>${f.line}행`);}
function clearDetails(){
const n=app.network,s=n.summary;
$('#details').innerHTML=`<div class="overview-icon" aria-hidden="true">⌖</div><h2>연결을 선택해 보세요.</h2><p class="inspector-intro">시설을 클릭하거나 ID를 검색하면<br>단면, 표고, 연결 정보를 확인할 수 있습니다.</p><div class="overview-list">${[['입력 노드',n.nodes.length],['배치 노드',s.coordinateCount],['배치 관로',s.renderedConduits],['방류구',s.counts.outfall],['저류시설',s.counts.storage],['펌프 / 위어',`${s.counts.pump} / ${s.counts.weir}`]].map(([a,b])=>`<div><span>${esc(a)}</span><b>${esc(b)}</b></div>`).join('')}</div><p class="notice warning">좌표 없는 방류구·관로와 평면 길이 0인 관로는 임의로 배치하지 않았습니다.</p><button class="focus-button" id="inspect-missing">배치 누락·좌표 겹침 보기 ↗</button>${section('모델을 읽는 기준',`<p class="fine-print">평면 좌표계와 단위는 미확인입니다.<br>XY 1단위=1m로 가정한 관망입니다.</p><p class="fine-print">맨홀 내경 1.2m는 표현용 가정입니다. 상단은 바닥 표고+최대 깊이이며 측량 지반고가 아닙니다.</p><p class="fine-print">원형·사각관은 입력 내부 단면을 표면으로 표시합니다. 벽 두께와 실제 시공 선형은 복원하지 않았습니다.</p>`)}`;
$('#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=`<span class="detail-type ${f.type}">${labels[f.type]}</span><h2 id="selected-id">${esc(f.id)}</h2>`;
const placed=isPlaced(f),hidden=!app.renderer.state.layers[layerOf(f)];
html+=`<button class="focus-button" id="focus-selected" ${placed?'':'disabled'}>${placed?'선택 시설로 이동 ⌖':'좌표·형상 확인 필요 · 이동 불가'}</button>`;
if(!placed)html+=`<p class="notice warning">${f.xy===null?'좌표가 없어 위치를 배치하지 않았습니다.':f.id==='UHR1-01411'?'양 끝의 평면 좌표가 같아 관로 형상을 생략했습니다.':'연결 노드 좌표 또는 단면이 없어 형상을 배치하지 않았습니다.'}</p>`;
if(hidden)html+='<p class="notice warning">이 시설의 표시 레이어가 꺼져 있습니다. 시설로 이동하면 해당 레이어를 켭니다.</p>';
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+='<p class="notice">내경 1.2m의 표현용 맨홀입니다. 벽 두께·뚜껑 규격은 미상이며 상단은 측량 지반고가 아닙니다.</p>';
if(f.type==='outfall')html+=section('방류 경계 조건',props([row('유형',esc(f.boundary)),row('입력 수위 / 시계열',esc(f.stage??'지정값 없음')),row('역류 방지문',esc(f.flapGate))]))+'<p class="notice">입력 경계 조건을 표시합니다. 계산된 수위나 유량이 아닙니다. 방류구 높이·크기 기호는 표현용입니다.</p>';
if(f.type==='storage')html+=section('저류 모델',props([row('형상 방식',esc(f.storageShape)),row('면적–깊이 곡선',esc(f.storageCurve))]))+'<p class="notice">높이는 입력 최대 깊이입니다. 평면 모양과 크기는 위치를 구분하는 기호이며 실제 저류조 치수가 아닙니다.</p>';
html+=section(`연결 시설 · ${f.connections.length}`,`<div class="connection-list">${f.connections.map(linked).join('')}</div>`);
}else{
html+=section('입력 연결 방향',`<div class="connection-list">${linked(f.fromNode)}${linked(f.toNode)}</div><p class="fine-print">${esc(f.fromNode)} → ${esc(f.toNode)}<br>INP 연결 순서이며 실제 유동 방향이 아닙니다.</p>`);
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+='<p class="notice warning">입력 길이와 좌표 거리의 차이가 5%를 넘습니다. 입력 길이에 맞춰 노드 위치를 옮기지 않았습니다.</p>';
html+='<p class="fine-print">좌표 거리와 차이는 XY 1단위=1m 가정입니다. 경사=(시작 관저고−끝 관저고)/각 길이. 양수는 입력 방향으로 낮아짐을 뜻하며 실제 흐름은 미해석입니다.</p>';
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+='<div class="profile-wrap"><canvas id="profile" aria-label="선택 관로의 입력 표고 기준 연결 모식도"></canvas><p class="profile-caption">원본 표고 연결 모식도 · 가로 길이는 축척 아님<br>상단=바닥 표고+최대 깊이 (측량 지반고 아님)</p></div>';
html+=section('형상 구성',props([row('중간 꺾임',`${f.vertices.length}개`),row('평면 연결',f.vertices.length?'입력 중간점 연결':'양 끝 좌표 직선 연결'),sourceRow(f)]));
html+='<p class="fine-print">내부 단면 표면만 표시하며 관 벽 두께는 미상입니다. 이 형상은 실제 시공 선형을 복원한 것이 아닙니다.</p>';
}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+='<p class="notice">기동·정지 깊이는 입력 제어값입니다. 펌프 기호 및 연결선은 모식 표현이며 병렬 펌프는 선택을 위해 기호를 옆으로 구분했습니다. 실제 배관고·펌프 외형을 뜻하지 않습니다.</p>';
}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+='<p class="notice">위어는 일반 관로와 구분한 위치·연결 기호입니다. 폭과 마루 형상의 실제 시공 상세는 재현하지 않습니다.</p>';
}
}
$('#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=>`<button class="search-result ${isPlaced(f)?'':'missing'}" data-pick="${f.pickId}"><span>${esc(f.id)}</span><small>${esc(labels[f.type])}${isPlaced(f)?'':' · 미배치'}</small></button>`).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=>`<div class="audit-row"><span>${esc(i.message)}</span>${i.ids[0]&&getFacility(i.ids[0])?`<button data-select="${esc(i.ids[0])}">시설 보기</button>`:''}</div>`).join(''):'<p class="fine-print">이 항목에 점검 사항이 없습니다.</p>';
$$('#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=`<div class="audit-summary-grid">${[['노드 / 관로',`${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])=>`<div>${esc(a)}<strong>${esc(b)}</strong></div>`).join('')}</div><p class="notice">CMS · 오프셋 ${esc(n.meta.offsetMode)} · 수리 제원 m<br>평면 좌표계 미확인 / MAP Units=${esc(n.meta.mapUnits)} / XY 단위 가정</p>`;
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();
+1
View File
@@ -0,0 +1 @@
{"meta":{"sourceName":"HC_A.inp","sha256":"1919a11dbb7a4b1d6988da1b9a4bda4529655fd89abc7285cf10703a1d7c1843","generatedAt":"2026-10-20T10:07:34.195280+00:00","options":{"FLOW_UNITS":"CMS","INFILTRATION":"HORTON","FLOW_ROUTING":"DYNWAVE","LINK_OFFSETS":"DEPTH","MIN_SLOPE":"0","ALLOW_PONDING":"NO","SKIP_STEADY_STATE":"NO","START_DATE":"01/01/1995","START_TIME":"00:00:00","REPORT_START_DATE":"01/01/1995","REPORT_START_TIME":"00:00:00","END_DATE":"01/02/1995","END_TIME":"03:00:00","SWEEP_START":"1/1","SWEEP_END":"12/31","DRY_DAYS":"0","REPORT_STEP":"00:01:00","WET_STEP":"00:01:00","DRY_STEP":"24:00:00","ROUTING_STEP":"0:01:00","RULE_STEP":"00:00:00","INERTIAL_DAMPING":"PARTIAL","NORMAL_FLOW_LIMITED":"FROUDE","FORCE_MAIN_EQUATION":"H-W","VARIABLE_STEP":"0.75","LENGTHENING_STEP":"0","MIN_SURFAREA":"0","MAX_TRIALS":"0","HEAD_TOLERANCE":"0","SYS_FLOW_TOL":"5","LAT_FLOW_TOL":"5","MINIMUM_STEP":"0.5","THREADS":"1"},"flowUnits":"CMS","physicalUnit":"m","physicalConversion":1.0,"offsetMode":"DEPTH","coordinateSystem":null,"mapUnits":"None","xyMetresPerUnit":1,"xyUnitConfirmed":false,"origin":[261686.5,612171.0,100],"bounds":{"minX":261101.0,"maxX":262272.0,"minY":611552.0,"maxY":612790.0,"minZ":100.02,"maxZ":166.54},"map":{"DIMENSIONS":["261042.450","611490.100","262330.550","612851.900"],"UNITS":["None"]},"manual":"https://nepis.epa.gov/Exe/ZyPURL.cgi?Dockey=P10145M6.TXT","assumptions":["XY: 1좌표단위=1m (표시 가정·미확인)","맨홀: 내경 1.2m는 표현용 가정, 벽 두께·뚜껑 규격 미상","저류시설·펌프·위어·방류구는 위치·연결의 모식 기호","중간점 없는 관로는 직선. 시공 선형 아님","상단=입력 바닥 표고+최대 깊이. 측량 지반고 아님"]},"summary":{"counts":{"junction":805,"outfall":9,"storage":1,"conduit":843,"pump":3,"weir":1},"shapes":{"CIRCULAR":659,"RECT_CLOSED":184},"coordinateCount":814,"vertexCount":0,"componentSizes":[726,43,21,16,6,3],"lengthCompared":842,"lengthOver5":185,"lengthOver20":60,"renderedConduits":841,"issues":{"missing_coordinate":1,"length_mismatch":185,"adverse_slope":26,"coincident_endpoints":1,"unplaced_link":1,"disconnected_components":1},"sectionCounts":{"OPTIONS":33,"EVAPORATION":2,"RAINGAGES":1,"SUBCATCHMENTS":784,"SUBAREAS":784,"INFILTRATION":784,"JUNCTIONS":805,"OUTFALLS":9,"STORAGE":1,"CONDUITS":843,"PUMPS":3,"WEIRS":1,"XSECTIONS":844,"LOSSES":842,"CURVES":18,"TIMESERIES":289,"REPORT":4,"MAP":2,"COORDINATES":814,"POLYGONS":1,"VERTICES":0},"priorComparison":{"junction":{"expected":805,"actual":805,"matches":true},"outfall":{"expected":9,"actual":9,"matches":true},"storage":{"expected":1,"actual":1,"matches":true},"conduit":{"expected":843,"actual":843,"matches":true},"CIRCULAR":{"expected":659,"actual":659,"matches":true},"RECT_CLOSED":{"expected":184,"actual":184,"matches":true}}},"nodes":[{"id":"MH3","type":"junction","invert":106.02,"maxDepth":0.75,"top":106.77,"xy":[262145.0,612156.0],"line":2421,"raw":["MH3","106.02","0.75","0","0","0"],"connections":["UHR1-0249"],"local":[458.5,-15.0,6.019999999999996]},{"id":"MH6","type":"junction","invert":104.99,"maxDepth":5.44,"top":110.42999999999999,"xy":[262245.0,612015.0],"line":2422,"raw":["MH6","104.99","5.44","0","0","0"],"connections":["UHR1-02901","UHR1-0292","UHR1-0253"],"local":[558.5,-156.0,4.989999999999995]},{"id":"MH7","type":"junction","invert":108.36,"maxDepth":2.5,"top":110.86,"xy":[262207.0,612290.0],"line":2423,"raw":["MH7","108.36","2.5","0","0","0"],"connections":["UHR1-0529","UHR1-0525","UHR1-0528"],"local":[520.5,119.0,8.36]},{"id":"MH9","type":"junction","invert":108.12,"maxDepth":2.44,"top":110.56,"xy":[262266.0,611928.0],"line":2424,"raw":["MH9","108.12","2.44","0","0","0"],"connections":["UHR1-0537","UHR1-0532","UHR1-0536"],"local":[579.5,-243.0,8.120000000000005]},{"id":"MH21","type":"junction","invert":104.61,"maxDepth":1.72,"top":106.33,"xy":[262063.0,611845.0],"line":2425,"raw":["MH21","104.61","1.72","0","0","0"],"connections":["UHR1-0361","UHR1-0355","UHR1-0360"],"local":[376.5,-326.0,4.609999999999999]},{"id":"MH22","type":"junction","invert":104.61,"maxDepth":1.72,"top":106.33,"xy":[262072.0,611840.0],"line":2426,"raw":["MH22","104.61","1.72","0","0","0"],"connections":["UHR1-03661","UHR1-03663","UHR1-03611"],"local":[385.5,-331.0,4.609999999999999]},{"id":"MH23","type":"junction","invert":104.58,"maxDepth":1.75,"top":106.33,"xy":[262075.0,611839.0],"line":2427,"raw":["MH23","104.58","1.75","0","0","0"],"connections":["UHR1-03661","UHR1-03662"],"local":[388.5,-332.0,4.579999999999998]},{"id":"MH24","type":"junction","invert":108.37,"maxDepth":1.63,"top":110.0,"xy":[261769.0,612281.0],"line":2428,"raw":["MH24","108.37","1.63","0","0","0"],"connections":["UHR1-0011"],"local":[82.5,110.0,8.370000000000005]},{"id":"MH25","type":"junction","invert":107.4,"maxDepth":1.06,"top":108.46000000000001,"xy":[261779.0,612254.0],"line":2429,"raw":["MH25","107.4","1.06","0","0","0"],"connections":["UHR1-0011","UHR1-0012"],"local":[92.5,83.0,7.4000Line truncated
+94
View File
@@ -0,0 +1,94 @@
Copyright (c) 2021, Kil Hyung-jin (https://github.com/orioncactus/pretendard),
with Reserved Font Name Pretendard.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+86
View File
@@ -0,0 +1,86 @@
import {vec,hex} from './math.js';
export const palette={junction:'#99c4ab',circular:'#64bdb9',rectangular:'#d7b47a',trapezoidal:'#b8a2d8',outfall:'#8bb5d4',storage:'#b8a2d8',pump:'#d8bd75',weir:'#d29a88',grid:'#35474a'};
export function layerOf(f){return ['junction','conduit','outfall'].includes(f.type)?f.type:'other';}
export function physicalPoint(n,origin){return n.xy?[n.xy[0]-origin[0],n.xy[1]-origin[1],n.invert-origin[2]]:null;}
// Inlet/outlet rings are vertical in the original XY frame. The cross-section
// centre is raised by half the full input height above the respective invert.
export function conduitPath(link,origin){
if(!link.pathXY||!link.renderable)return [];
const lengths=[0];for(let i=1;i<link.pathXY.length;i++)lengths.push(lengths.at(-1)+Math.hypot(link.pathXY[i][0]-link.pathXY[i-1][0],link.pathXY[i][1]-link.pathXY[i-1][1]));
const total=lengths.at(-1);if(total<=1e-9)return [];
return link.pathXY.map((p,i)=>[p[0]-origin[0],p[1]-origin[1],link.invertStart-origin[2]+(link.invertEnd-link.invertStart)*lengths[i]/total]);
}
class Batch {
constructor(mode){this.mode=mode;this.data=[];this.count=0;}
vertex(base,offset,normal,color,id,kind=0,slice=0){this.data.push(...base,...offset,...normal,...color,id,kind,slice);this.count++;}
triangle(bases,offsets,color,id,kind=0,slices=[0,0,0]){
const p=bases.map((b,i)=>vec.add(b,offsets[i]));const n=vec.norm(vec.cross(vec.sub(p[1],p[0]),vec.sub(p[2],p[0])));
for(let i=0;i<3;i++)this.vertex(bases[i],offsets[i],n,color,id,kind,slices[i]);
}
quad(bases,offsets,color,id,kind=0,slices=[0,0,0,0]){
for(const tri of [[0,1,2],[0,2,3]])this.triangle(tri.map(i=>bases[i]),tri.map(i=>offsets[i]),color,id,kind,tri.map(i=>slices[i]));
}
}
function vessel(batch,point,height,radius,color,id,steps=14,kind=1){
const top=[point[0],point[1],point[2]+height];
for(let i=0;i<steps;i++){
const a=i/steps*Math.PI*2,b=(i+1)/steps*Math.PI*2,u=[radius*Math.cos(a),radius*Math.sin(a),0],v=[radius*Math.cos(b),radius*Math.sin(b),0];
batch.quad([point,point,top,top],[u,v,v,u],color,id,kind);
batch.triangle([top,top,top],[[0,0,0],u,v],color,id,kind);
batch.triangle([point,point,point],[[0,0,0],v,u],color,id,kind);
}
}
function tube(batch,link,origin,color,id){
const path=conduitPath(link,origin),section=link.section;
let ring=section.shape==='CIRCULAR'?Array.from({length:12},(_,i)=>{const t=i/12*Math.PI*2;return[section.width/2*Math.cos(t),section.height/2*(1+Math.sin(t)),Math.sin(t)];}):[[-section.width/2,0,-1],[section.width/2,0,-1],[section.width/2,section.height,1],[-section.width/2,section.height,1]];
const open=section.shape==='TRAPEZOIDAL'||section.shape==='RECT_OPEN';
if(open){
const h=section.height,w=section.width,left=section.sideSlopes?.[0]??0,right=section.sideSlopes?.[1]??0;
ring=[[-w/2-left*h,h,1],[-w/2,0,-1],[w/2,0,-1],[w/2+right*h,h,1]];
}
for(let k=1;k<path.length;k++){
const a=path[k-1],b=path[k],dx=b[0]-a[0],dy=b[1]-a[1],len=Math.hypot(dx,dy);if(len<1e-9)continue;
const nx=-dy/len,ny=dx/len;
for(let barrel=0;barrel<section.barrels;barrel++){
// Barrel separation is schematic when counts exceed one; the supplied file has one throughout.
const lateral=(barrel-(section.barrels-1)/2)*(section.topWidth??section.width)*1.1;
for(let i=0;i<ring.length-(open?1:0);i++){
const r=ring[i],s=ring[(i+1)%ring.length],u=[nx*(r[0]+lateral),ny*(r[0]+lateral),r[1]],v=[nx*(s[0]+lateral),ny*(s[0]+lateral),s[1]];
batch.quad([a,b,b,a],[u,u,v,v],color,id,2,[r[2],r[2],s[2],s[2]]);
}
}
}
return path;
}
export function buildGeometry(network,facilities){
const batches={};for(const layer of ['junction','conduit','outfall','other','grid'])batches[layer]={mesh:new Batch('triangles'),lines:new Batch('lines'),points:new Batch('points')};
const origin=network.meta.origin,nodes=new Map(network.nodes.map(n=>[n.id,n]));
for(const f of facilities){
const group=batches[layerOf(f)],id=f.pickId,color=hex(palette[f.type==='conduit'?(f.section?.shape==='CIRCULAR'?'circular':f.section?.shape==='TRAPEZOIDAL'?'trapezoidal':'rectangular'):f.type]);
if(f.xy){
const point=physicalPoint(f,origin);const height=f.maxDepth??1;
if(f.type==='junction')vessel(group.mesh,point,height,.6,color,id);
else if(f.type==='storage')vessel(group.mesh,point,height,1.7,color,id,6,1);
else vessel(group.mesh,point,1,.8,color,id,4,3);
// Five-pixel minimum marker is a position glyph, not a physical manhole diameter.
group.points.vertex([point[0],point[1],point[2]+height],[0,0,0],[0,0,1],color,id,3);
}else if(f.type==='conduit'&&f.renderable){
const path=tube(group.mesh,f,origin,color,id);
for(let i=1;i<path.length;i++)for(const p of [path[i-1],path[i]])group.lines.vertex(p,[0,0,f.section.height/2],[0,0,1],color,id,4);
}else if(['pump','weir'].includes(f.type)&&f.renderable){
const na=nodes.get(f.fromNode),nb=nodes.get(f.toNode),pa=physicalPoint(na,origin),pb=physicalPoint(nb,origin);
const z=f.type==='weir'?f.crestElevation-origin[2]:Math.max(pa[2],pb[2]);
const a=[pa[0],pa[1],z],b=[pb[0],pb[1],z],mid=vec.scale(vec.add(a,b),.5);
// Separate glyphs allow parallel pumps to be selected; spacing is a symbolic display offset.
const peers=facilities.filter(p=>p.type===f.type&&p.fromNode===f.fromNode&&p.toNode===f.toNode),slot=peers.indexOf(f)-(peers.length-1)/2;
const direction=vec.norm([b[0]-a[0],b[1]-a[1],0]);mid[0]+=-direction[1]*slot*3;mid[1]+=direction[0]*slot*3;
vessel(group.mesh,mid,1.3,f.type==='pump'?.8:1.1,color,id,f.type==='pump'?8:4,3);
group.points.vertex([mid[0],mid[1],z+1.3],[0,0,0],[0,0,1],color,id,3);
for(const p of [a,mid,mid,b])group.lines.vertex(p,[0,0,0],[0,0,1],color,id,4);
f.symbolPoint=[mid[0],mid[1],z+1.3];
}
}
const bound=network.meta.bounds,extent=Math.max(bound.maxX-bound.minX,bound.maxY-bound.minY)*.72,grid=batches.grid.lines;
for(let n=-Math.ceil(extent/100)*100;n<=extent;n+=100)for(const pair of [[[-extent,n,-1],[extent,n,-1]],[[n,-extent,-1],[n,extent,-1]]])for(const p of pair)grid.vertex(p,[0,0,0],[0,0,1],hex(palette.grid),0,4);
return batches;
}
+57
View File
@@ -0,0 +1,57 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#f5f7f3">
<meta name="description" content="로컬 SWMM 입력 제원으로 살펴보는 화천군 맨홀 및 우수관로 3D 뷰어">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' blob: data:; connect-src 'self'; object-src 'none'; base-uri 'none'">
<title>화천군 맨홀 및 우수관로 3D 뷰어</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="styles.css"><link rel="stylesheet" href="studio-nav.css">
<script type="module" src="app.js"></script>
<link rel="stylesheet" href="viewer-readability.css">
</head>
<body class="network-page">
<header class="topbar"><a class="brand" href="./"><span class="brand-icon" aria-hidden="true">⌘</span><span>HWACHEON<small>STORMWATER NETWORK</small></span></a><div class="title-block"><h1>화천군 맨홀 및 우수관로 3D 뷰어</h1><span>입력 제원 기반 관망 탐색</span></div><span class="local-badge"><i></i> LOCAL ONLY</span><nav class="studio-nav" aria-label="화천군 관망"><a href="./" aria-current="page">화천군 관망</a></nav></header>
<main class="app-grid">
<aside class="controls" aria-label="관망 제어">
<div class="section-kicker">NETWORK EXPLORER <span>01</span></div>
<h2>관망을 입체로,<br><span>연결을 더 분명하게.</span></h2>
<p class="subtle">SWMM 입력 제원을 공간으로 살펴봅니다.<br>형상과 연결 높이를 선택해 확인하세요.</p>
<div class="summary-cards"><div><span>접합부</span><strong id="node-count">—</strong></div><div><span>관로</span><strong id="pipe-count">—</strong></div></div>
<section><div class="section-heading"><h3>시설 찾기</h3><span class="small">ID 검색</span></div><label class="search-wrap"><span aria-hidden="true">⌕</span><input id="search" type="search" placeholder="MH3, UHR1-0249…" autocomplete="off" aria-label="시설 ID 검색"></label><div id="search-status" class="small" aria-live="polite"></div><div id="search-results" class="search-results" aria-label="검색 결과"></div></section>
<section><div class="section-heading"><h3>시설 표시</h3><button id="show-all" class="text-button">모두 표시</button></div><div class="layer-list">
<label><span><i class="legend-dot junction"></i>맨홀 <small id="layer-junction-count"></small></span><input id="show-junction" data-layer="junction" type="checkbox" checked></label>
<label><span><i class="legend-dot conduit"></i>관로 <small id="layer-conduit-count"></small></span><input id="show-conduit" data-layer="conduit" type="checkbox" checked></label>
<label><span><i class="legend-dot outfall"></i>방류구 <small id="layer-outfall-count"></small></span><input id="show-outfall" data-layer="outfall" type="checkbox" checked></label>
<label><span><i class="legend-dot other"></i>저류·펌프·위어 <small id="layer-other-count"></small></span><input id="show-other" data-layer="other" type="checkbox" checked></label>
</div></section>
<section><div class="section-heading"><h3>높이와 형상</h3><button id="actual-scale" class="text-button">실제 제원 비율</button></div>
<label class="range-heading" for="height-scale">표고·높이 배율 <output id="height-output">×3</output></label><input id="height-scale" type="range" min="1" max="10" step="0.5" value="3"><div class="range-ends"><span>×1</span><span>×10</span></div>
<label class="range-heading" for="size-scale">관로 단면·맨홀 폭 배율 <output id="size-output">×3</output></label><input id="size-scale" type="range" min="1" max="8" step="0.5" value="3"><div class="range-ends"><span>×1</span><span>×8</span></div>
<label class="toggle-label"><span>연결부 내부 보기<small>맨홀 앞쪽과 관로 상부 열기</small></span><input id="section-mode" type="checkbox"></label>
<p class="fine-print">맨홀 내경 1.2m는 표현용 가정입니다.<br>관로 수직 단면에는 두 배율을 곱해 적용합니다.<br>제원 패널에는 입력 원값을 표시합니다.</p>
</section>
<button id="open-audit" class="audit-button"><span><span class="audit-icon">!</span> 데이터 점검</span><strong id="audit-count">—</strong><span>↗</span></button>
<p class="source-label">SOURCE · <span id="source-name">HC_A.inp</span><br>수리 해석 없이 입력 제원만 표시합니다.</p>
</aside>
<section class="viewer" aria-label="인터랙티브 3D 관망">
<canvas id="scene" tabindex="0" aria-label="관망 3D 화면. 드래그 회전, 휠 확대, Shift 또는 우클릭 드래그 이동. 시설을 클릭하면 제원을 표시합니다."></canvas>
<div class="viewport-heading"><div><span class="section-kicker">HWACHEON / DRAINAGE SYSTEM</span><h2 id="view-name">전체 관망</h2><p id="visible-count">데이터를 불러오는 중…</p></div><span id="view-badge" class="view-badge">입체 보기</span></div>
<div class="scale-badges" aria-live="polite"><span id="height-badge">높이 ×3</span><span id="size-badge">단면·맨홀 폭 ×3</span><span id="section-badge" hidden>내부 보기</span></div>
<div class="coordinate-note">좌표계 미확인 <span>·</span> XY 1단위 = 1m 가정</div>
<div id="loading" class="scene-message" role="status">관망을 준비하고 있습니다…</div>
<div id="fatal-error" class="scene-message error" role="alert" hidden></div>
<div class="legend-panel"><span class="legend-title">단면과 제원</span><div><i class="line-key circular"></i>원형 <span id="circle-count"></span></div><div><i class="line-key rectangular"></i>사각 폐합 <span id="rect-count"></span></div><small>관이 클수록 실제 입력 단면이 큽니다.<br>전체 보기에서는 중심선·최소 5px 위치기호 병기.</small></div>
<div class="axes" aria-label="원본 좌표의 X, Y와 표고 Z"><canvas id="axes" width="110" height="100"></canvas><span>INPUT COORDINATES</span></div>
<div class="view-toolbar" aria-label="시점 제어"><button id="fit" title="표시 중인 전체 관망 맞추기 (F)">⛶ <span>전체 보기</span></button><i></i><button data-view="iso" aria-pressed="true">입체</button><button data-view="plan" aria-pressed="false">평면</button><button data-view="side" aria-pressed="false">측면</button><i></i><button id="reset" title="배율·레이어·시점 초기화 (R)">↺ <span>초기화</span></button></div>
<div class="viewer-footer"><span>드래그 회전 <i>·</i> 휠 확대 <i>·</i> Shift / 우클릭 드래그 이동</span><button id="open-help" class="text-button">조작 안내 ⓘ</button></div>
<div class="datum-note">기준 그리드 · 지형 아님</div>
</section>
<aside class="inspector" aria-label="시설 제원"><div class="section-kicker">FACILITY DETAILS <span>02</span></div><div id="details"></div></aside>
</main>
<footer class="statusbar"><span><i class="status-light"></i> <span id="render-status">준비 중</span></span><span id="model-status">단위 · m / XY 단위는 가정</span><span>수위·유량·실제 흐름 미해석</span></footer>
<div id="toast" role="status" aria-live="polite"></div>
<dialog id="audit-dialog"><div class="dialog-heading"><div><span class="section-kicker">DATA AUDIT</span><h2>입력 자료를 먼저 확인하세요.</h2></div><button class="close-dialog" aria-label="데이터 점검 닫기">✕</button></div><div id="audit-summary"></div><label class="audit-filter-label">점검 항목 <select id="audit-filter"><option value="placement">배치 누락·좌표 겹침</option><option value="length_mismatch">입력 길이와 좌표 거리 차이</option><option value="adverse_slope">입력 방향 기준 역경사</option><option value="all">전체 점검</option></select></label><div id="audit-list" class="audit-list"></div><p class="fine-print">거리 차이는 XY 1단위=1m 가정에 의존합니다. 5%는 검토용 분류 기준이며 오류 확정 기준이 아닙니다.</p></dialog>
<dialog id="help-dialog"><div class="dialog-heading"><h2>관망 둘러보기</h2><button class="close-dialog" aria-label="조작 안내 닫기">✕</button></div><dl class="help-grid"><dt>회전</dt><dd>왼쪽 버튼 드래그 / 방향키</dd><dt>확대·축소</dt><dd>마우스 휠 / +, − / 두 손가락</dd><dt>이동</dt><dd>Shift + 드래그 / 우클릭 드래그 / 두 손가락 이동</dd><dt>전체 맞추기</dt><dd>전체 보기 / F</dd><dt>시점</dt><dd>입체·평면·측면 버튼 / 1·2·3</dd><dt>초기화</dt><dd>배율·표시·선택·시점 모두 초기화 / R</dd><dt>선택·연결 확인</dt><dd>시설 클릭 또는 ID 검색 → 시설로 이동 → 내부 보기</dd></dl><p class="notice">관로 상부와 맨홀 앞쪽을 열어 연결 높이를 확인할 수 있습니다. 연결부는 입력 제원에 따른 배치이며 실제 시공 상세가 아닙니다.</p></dialog>
</body></html>
+13
View File
@@ -0,0 +1,13 @@
export const vec = {
sub:(a,b)=>a.map((v,i)=>v-b[i]),
add:(a,b)=>a.map((v,i)=>v+b[i]),
scale:(a,s)=>a.map(v=>v*s),
dot:(a,b)=>a.reduce((s,v,i)=>s+v*b[i],0),
cross:(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],
norm:a=>{const n=Math.hypot(...a);return n>1e-10?a.map(v=>v/n):[0,0,1];},
};
export function multiply(a,b){const out=new Float32Array(16);for(let c=0;c<4;c++)for(let r=0;r<4;r++)for(let k=0;k<4;k++)out[c*4+r]+=a[k*4+r]*b[c*4+k];return out;}
export function perspective(aspect,near,far){const f=1/Math.tan(Math.PI/9);return new Float32Array([f/aspect,0,0,0,0,f,0,0,0,0,(far+near)/(near-far),-1,0,0,2*far*near/(near-far),0]);}
export function lookAt(eye,target){const f=vec.norm(vec.sub(target,eye)),s=vec.norm(vec.cross(f,[0,0,1])),u=vec.cross(s,f);return new Float32Array([s[0],u[0],-f[0],0,s[1],u[1],-f[1],0,s[2],u[2],-f[2],0,-vec.dot(s,eye),-vec.dot(u,eye),vec.dot(f,eye),1]);}
export function project(p,m,width,height){const q=[...p,1],v=[0,0,0,0];for(let r=0;r<4;r++)for(let c=0;c<4;c++)v[r]+=m[c*4+r]*q[c];return{ x:(v[0]/v[3]+1)*width/2,y:(1-v[1]/v[3])*height/2,depth:v[2]/v[3],inFront:v[3]>0};}
export function hex(s){return [1,3,5].map(i=>parseInt(s.slice(i,i+2),16)/255);}
+132
View File
@@ -0,0 +1,132 @@
import {vec,multiply,perspective,lookAt,project} from './math.js';
import {buildGeometry,layerOf,physicalPoint,conduitPath} from './geometry.js';
const vertex=`precision highp float;
attribute vec3 aBase;attribute vec3 aOffset;attribute vec3 aNormal;attribute vec3 aColor;attribute float aId;attribute float aKind;attribute float aSlice;
uniform mat4 uMatrix;uniform float uHeight;uniform float uSize;uniform float uDpr;uniform float uPointSize;
varying vec3 vNormal;varying vec3 vColor;varying float vId;varying float vKind;varying float vSlice;varying vec3 vOffset;
void main(){vec3 p=aBase+aOffset*uSize;p.z*=uHeight;gl_Position=uMatrix*vec4(p,1.0);gl_PointSize=uPointSize*uDpr;
vNormal=normalize(aNormal/vec3(1.0,1.0,uHeight));vColor=aColor;vId=aId;vKind=aKind;vSlice=aSlice;vOffset=aOffset;}`;
const fragment=`precision highp float;
uniform vec3 uGlowColor;uniform float uGlow;uniform float uSelected;uniform float uPicking;uniform float uPoints;uniform float uSection;uniform vec2 uCameraDir;
varying vec3 vNormal;varying vec3 vColor;varying float vId;varying float vKind;varying float vSlice;varying vec3 vOffset;
void main(){
if(uPoints>.5&&length(gl_PointCoord-vec2(.5))>.5)discard;
if(uSection>.5){if(vKind>.5&&vKind<1.5&&dot(vOffset.xy,uCameraDir)>0.0)discard;if(vKind>1.5&&vKind<2.5&&vSlice>.03)discard;}
if(uPicking>.5){float id=floor(vId+.5);if(id<.5)discard;gl_FragColor=vec4(mod(id,256.0)/255.0,mod(floor(id/256.0),256.0)/255.0,floor(id/65536.0)/255.0,1.0);return;}
vec3 n=normalize(vNormal);if(!gl_FrontFacing)n=-n;float light=.46+.42*max(dot(n,normalize(vec3(-.45,-.65,1.0))),0.0)+.15*max(dot(n,normalize(vec3(.6,.3,.5))),0.0);
vec3 color=vColor;if(vKind<3.5&&uPoints<.5)color*=light;
if(abs(vId-uSelected)<.1&&uSelected>.5)color=mix(vec3(.97,.41,.14),vec3(1.0,.78,.3),.32*max(n.z,0.0));
if(uGlow>0.0&&abs(vId-uSelected)>.1)color=mix(color,min(vec3(1.0),uGlowColor*1.25+vec3(.08)),uGlow);
gl_FragColor=vec4(color,1.0);
}`;
export class NetworkRenderer {
constructor(canvas,network,facilities,onSelect,onFrame){
this.canvas=canvas;this.network=network;this.facilities=facilities;this.onSelect=onSelect;this.onFrame=onFrame;
this.state={height:3,size:3,section:false,selected:0,layers:{junction:true,conduit:true,outfall:true,other:true}};
this.camera={azimuth:-.92,elevation:.73,distance:2400,target:[0,0,30]};this.view='iso';this.autoFit=true;this.frame=0;this.matrix=null;this.lastRenderMs=0;
this.gl=canvas.getContext('webgl',{alpha:true,antialias:true,preserveDrawingBuffer:true});
if(!this.gl)throw Error('WebGL을 시작할 수 없습니다. 브라우저의 하드웨어 가속 설정을 확인해 주세요.');
const gl=this.gl;
const shader=(type,source)=>{const s=gl.createShader(type);gl.shaderSource(s,source);gl.compileShader(s);if(!gl.getShaderParameter(s,gl.COMPILE_STATUS))throw Error(gl.getShaderInfoLog(s));return s;};
const p=gl.createProgram();gl.attachShader(p,shader(gl.VERTEX_SHADER,vertex));gl.attachShader(p,shader(gl.FRAGMENT_SHADER,fragment));gl.linkProgram(p);if(!gl.getProgramParameter(p,gl.LINK_STATUS))throw Error(gl.getProgramInfoLog(p));this.program=p;
this.attributes=Object.fromEntries(['Base','Offset','Normal','Color','Id','Kind','Slice'].map(n=>[n,gl.getAttribLocation(p,'a'+n)]));
this.uniforms=Object.fromEntries(['Matrix','Height','Size','Dpr','PointSize','Selected','Picking','Points','Section','CameraDir','Glow','GlowColor'].map(n=>[n,gl.getUniformLocation(p,'u'+n)]));
this.batches={};const geometry=buildGeometry(network,facilities);
for(const [layer,groups] of Object.entries(geometry)){this.batches[layer]={};for(const [kind,batch] of Object.entries(groups)){
const buffer=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,buffer);gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(batch.data),gl.STATIC_DRAW);this.batches[layer][kind]={buffer,count:batch.count};
}}
this.picking={fbo:gl.createFramebuffer(),texture:gl.createTexture(),depth:gl.createRenderbuffer()};
this.interactions();this.observer=new ResizeObserver(()=>this.resize());this.observer.observe(canvas);this.resize();this.fit();
}
visible(f){return this.state.layers[layerOf(f)]&&(f.xy!=null||f.renderable);}
set(patch){const oldHeight=this.state.height;Object.assign(this.state,patch);if(patch.height||patch.size){if(this.autoFit)this.fit();else if(patch.height)this.camera.target[2]*=patch.height/oldHeight;}this.request();}
setLayer(layer,value){this.state.layers[layer]=value;this.request();}
request(){if(!this.frame)this.frame=requestAnimationFrame(()=>{this.frame=0;this.draw();});}
resize(){
const previousWidth=this.width,previousHeight=this.height;const r=this.canvas.getBoundingClientRect();this.width=Math.max(1,r.width);this.height=Math.max(1,r.height);this.dpr=Math.min(devicePixelRatio||1,2);
this.canvas.width=Math.round(this.width*this.dpr);this.canvas.height=Math.round(this.height*this.dpr);const gl=this.gl;
gl.bindTexture(gl.TEXTURE_2D,this.picking.texture);gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,this.canvas.width,this.canvas.height,0,gl.RGBA,gl.UNSIGNED_BYTE,null);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.NEAREST);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.NEAREST);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
gl.bindRenderbuffer(gl.RENDERBUFFER,this.picking.depth);gl.renderbufferStorage(gl.RENDERBUFFER,gl.DEPTH_COMPONENT16,this.canvas.width,this.canvas.height);gl.bindFramebuffer(gl.FRAMEBUFFER,this.picking.fbo);gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,this.picking.texture,0);gl.framebufferRenderbuffer(gl.FRAMEBUFFER,gl.DEPTH_ATTACHMENT,gl.RENDERBUFFER,this.picking.depth);if(gl.checkFramebufferStatus(gl.FRAMEBUFFER)!==gl.FRAMEBUFFER_COMPLETE)throw Error('시설 선택용 렌더 버퍼를 만들 수 없습니다.');gl.bindFramebuffer(gl.FRAMEBUFFER,null);if(this.autoFit&&previousWidth&&(previousWidth!==this.width||previousHeight!==this.height))this.fit();this.request();
}
pointsFor(f){
if(f.xy){const p=physicalPoint(f,this.network.meta.origin);return[p,[p[0],p[1],p[2]+(f.maxDepth??1)]];}
if(f.type==='conduit'&&f.renderable)return conduitPath(f,this.network.meta.origin).flatMap(p=>[p,[p[0],p[1],p[2]+f.section.height*this.state.size]]);
return f.symbolPoint?[f.symbolPoint]:[];
}
fit(){
this.autoFit=true;const pts=this.facilities.filter(f=>this.visible(f)).flatMap(f=>this.pointsFor(f));if(!pts.length){this.request();return;}
const min=[0,1,2].map(i=>Math.min(...pts.map(p=>p[i]*(i===2?this.state.height:1)))),max=[0,1,2].map(i=>Math.max(...pts.map(p=>p[i]*(i===2?this.state.height:1))));
this.camera.target=min.map((n,i)=>(n+max[i])/2);const span=Math.max(max[0]-min[0],max[1]-min[1],(max[2]-min[2]));
this.camera.distance=Math.max(30,span*1.65/Math.min(this.width/this.height,.95));
// Fit the actual projected envelope into the unobstructed area, rather than
// placing an asymmetric network behind the header or view toolbar.
const world=pts.map(p=>[p[0],p[1],p[2]*this.state.height]);
for(let pass=0;pass<3;pass++){
const c=this.camera,eye=c.target.map((v,i)=>v+c.distance*[Math.cos(c.elevation)*Math.cos(c.azimuth),Math.cos(c.elevation)*Math.sin(c.azimuth),Math.sin(c.elevation)][i]);
const m=multiply(perspective(this.width/this.height,.03,100000),lookAt(eye,c.target));
const screen=world.map(p=>project(p,m,this.width,this.height));
const minX=Math.min(...screen.map(p=>p.x)),maxX=Math.max(...screen.map(p=>p.x)),minY=Math.min(...screen.map(p=>p.y)),maxY=Math.max(...screen.map(p=>p.y));
const desiredX=this.width/2,desiredY=(190+this.height-125)/2;
this.pan(desiredX-(minX+maxX)/2,desiredY-(minY+maxY)/2);
if(pass<2){const ratio=Math.max((maxX-minX)/Math.max(100,this.width-100),(maxY-minY)/Math.max(100,this.height-330));this.camera.distance*=Math.max(.6,ratio*1.08);}
}
this.request();
}
focus(f){
const points=this.pointsFor(f);if(!points.length)return false;this.autoFit=false;
const min=[0,1,2].map(i=>Math.min(...points.map(p=>p[i]*(i===2?this.state.height:1)))),max=[0,1,2].map(i=>Math.max(...points.map(p=>p[i]*(i===2?this.state.height:1))));
this.camera.target=min.map((v,i)=>(v+max[i])/2);
const span=Math.hypot(...min.map((n,i)=>max[i]-n));this.camera.distance=Math.max(f.type==='conduit'?18:28,span*2.2/Math.min(this.width/this.height,.95));this.request();return true;
}
setView(view){this.view=view;if(view==='plan')Object.assign(this.camera,{azimuth:-Math.PI/2,elevation:Math.PI/2-.0001});else if(view==='side')Object.assign(this.camera,{azimuth:-Math.PI/2,elevation:0});else Object.assign(this.camera,{azimuth:-.92,elevation:.73});if(this.autoFit)this.fit();this.request();}
draw(picking=false){
const started=performance.now(),gl=this.gl,u=this.uniforms,c=this.camera;
const eye=[c.target[0]+c.distance*Math.cos(c.elevation)*Math.cos(c.azimuth),c.target[1]+c.distance*Math.cos(c.elevation)*Math.sin(c.azimuth),c.target[2]+c.distance*Math.sin(c.elevation)];
this.matrix=multiply(perspective(this.width/this.height,Math.max(.03,c.distance/15000),Math.max(30000,c.distance*5)),lookAt(eye,c.target));
gl.bindFramebuffer(gl.FRAMEBUFFER,picking?this.picking.fbo:null);gl.viewport(0,0,this.canvas.width,this.canvas.height);gl.clearColor(0,0,0,0);gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);gl.enable(gl.DEPTH_TEST);gl.depthFunc(gl.LEQUAL);gl.disable(gl.BLEND);gl.disable(gl.DITHER);gl.disable(gl.CULL_FACE);gl.useProgram(this.program);
gl.uniformMatrix4fv(u.Matrix,false,this.matrix);gl.uniform1f(u.Height,this.state.height);gl.uniform1f(u.Size,this.state.size);gl.uniform1f(u.Dpr,this.dpr);gl.uniform1f(u.PointSize,picking?9:5);gl.uniform1f(u.Selected,this.state.selected);gl.uniform1f(u.Picking,picking?1:0);gl.uniform1f(u.Section,this.state.section?1:0);gl.uniform2f(u.CameraDir,Math.cos(c.azimuth),Math.sin(c.azimuth));
for(const [layer,groups] of Object.entries(this.batches)){
if(layer==='grid'&&picking)continue;if(layer!=='grid'&&!this.state.layers[layer])continue;
gl.uniform1f(u.Glow,picking?0:(this.glowForLayer?.(layer)||0));
gl.uniform3fv(u.GlowColor,this.glowColorForLayer?.(layer)||[0,0,0]);
for(const [kind,b] of Object.entries(groups)){
if(!b.count)continue;gl.bindBuffer(gl.ARRAY_BUFFER,b.buffer);
let offset=0;for(const [name,size] of [['Base',3],['Offset',3],['Normal',3],['Color',3],['Id',1],['Kind',1],['Slice',1]]){const a=this.attributes[name];gl.enableVertexAttribArray(a);gl.vertexAttribPointer(a,size,gl.FLOAT,false,60,offset*4);offset+=size;}
gl.uniform1f(u.Points,kind==='points'?1:0);gl.drawArrays(kind==='mesh'?gl.TRIANGLES:kind==='lines'?gl.LINES:gl.POINTS,0,b.count);
}
}
if(!picking){this.drawAxes();this.lastRenderMs=performance.now()-started;this.onFrame?.(this);}
}
pick(clientX,clientY){
const r=this.canvas.getBoundingClientRect(),gl=this.gl,x=Math.round((clientX-r.left)*this.dpr),y=Math.round((r.bottom-clientY)*this.dpr),radius=Math.ceil(4*this.dpr),x0=Math.max(0,x-radius),y0=Math.max(0,y-radius),w=Math.min(this.canvas.width-x0,2*radius+1),h=Math.min(this.canvas.height-y0,2*radius+1);
if(w<=0||h<=0)return;this.draw(true);const bytes=new Uint8Array(w*h*4);gl.readPixels(x0,y0,w,h,gl.RGBA,gl.UNSIGNED_BYTE,bytes);let best=null;
for(let j=0;j<h;j++)for(let i=0;i<w;i++){const k=(j*w+i)*4,id=bytes[k]+bytes[k+1]*256+bytes[k+2]*65536;if(!id)continue;const distance=(i+x0-x)**2+(j+y0-y)**2;if(!best||distance<best.distance)best={id,distance};}
gl.bindFramebuffer(gl.FRAMEBUFFER,null);this.request();if(best)this.onSelect(this.facilities.find(f=>f.pickId===best.id));
}
projectFacility(f){const pts=this.pointsFor(f);if(!pts.length||!this.matrix)return null;const p=f.type==='conduit'?[0,1,2].map(i=>pts.reduce((sum,v)=>sum+v[i],0)/pts.length):pts.at(-1).slice();p[2]*=this.state.height;return project(p,this.matrix,this.width,this.height);}
drawAxes(){
const canvas=document.querySelector('#axes'),ctx=canvas.getContext('2d'),c=this.camera;ctx.clearRect(0,0,110,100);const cx=54,cy=54;
const right=[-Math.sin(c.azimuth),Math.cos(c.azimuth),0],up=[-Math.cos(c.azimuth)*Math.sin(c.elevation),-Math.sin(c.azimuth)*Math.sin(c.elevation),Math.cos(c.elevation)];
for(const [v,name,color] of [[[1,0,0],'X','#d2a18d'],[[0,1,0],'Y','#8fbea3'],[[0,0,1],'Z','#9ebed4']]){
const dx=vec.dot(v,right)*27,dy=-vec.dot(v,up)*27;ctx.strokeStyle=color;ctx.lineWidth=1.4;ctx.beginPath();ctx.moveTo(cx,cy);ctx.lineTo(cx+dx,cy+dy);ctx.stroke();ctx.fillStyle=color;ctx.font='10px system-ui';ctx.textAlign='center';ctx.fillText(name,cx+dx*1.3,cy+dy*1.3+3);
}ctx.fillStyle='#adc8b9';ctx.beginPath();ctx.arc(cx,cy,2,0,Math.PI*2);ctx.fill();
}
pan(dx,dy,manual=false){
if(manual)this.autoFit=false;const c=this.camera,units=c.distance*.73/this.height,right=[-Math.sin(c.azimuth),Math.cos(c.azimuth),0],up=[-Math.cos(c.azimuth)*Math.sin(c.elevation),-Math.sin(c.azimuth)*Math.sin(c.elevation),Math.cos(c.elevation)];
c.target=c.target.map((v,i)=>v-dx*units*right[i]+dy*units*up[i]);
}
interactions(){
const el=this.canvas,pointers=new Map();let moved=0,start=null,previous=null;
el.addEventListener('contextmenu',e=>e.preventDefault());
el.addEventListener('pointerdown',e=>{el.focus({preventScroll:true});el.setPointerCapture(e.pointerId);pointers.set(e.pointerId,[e.clientX,e.clientY]);if(pointers.size===1){start=[e.clientX,e.clientY];moved=0;}else moved=100;previous=[...pointers.values()];});
el.addEventListener('pointermove',e=>{if(!pointers.has(e.pointerId))return;pointers.set(e.pointerId,[e.clientX,e.clientY]);const now=[...pointers.values()];if(now.length===1&&previous?.length===1){const dx=now[0][0]-previous[0][0],dy=now[0][1]-previous[0][1];moved+=Math.hypot(dx,dy);if(e.shiftKey||e.buttons===2||e.buttons===4)this.pan(dx,dy,true);else{this.autoFit=false;this.camera.azimuth-=dx*.007;this.camera.elevation=Math.max(-.15,Math.min(Math.PI/2-.0001,this.camera.elevation+dy*.006));this.view='custom';}}
else if(now.length===2&&previous?.length===2){const mid=p=>[(p[0][0]+p[1][0])/2,(p[0][1]+p[1][1])/2],n=mid(now),p=mid(previous);this.pan(n[0]-p[0],n[1]-p[1],true);const d=p=>Math.hypot(p[0][0]-p[1][0],p[0][1]-p[1][1]);this.camera.distance=Math.max(2,Math.min(15000,this.camera.distance*d(previous)/Math.max(1,d(now))));}
previous=now;this.request();});
el.addEventListener('pointerup',e=>{if(pointers.size===1&&moved<5&&start&&e.button===0)this.pick(e.clientX,e.clientY);pointers.delete(e.pointerId);previous=[...pointers.values()];});
el.addEventListener('pointercancel',e=>{pointers.delete(e.pointerId);previous=[...pointers.values()];moved=100;});
el.addEventListener('wheel',e=>{e.preventDefault();this.autoFit=false;this.camera.distance=Math.max(2,Math.min(15000,this.camera.distance*Math.exp(Math.max(-1,Math.min(1,e.deltaY*.001)))));this.request();},{passive:false});
el.addEventListener('keydown',e=>{this.autoFit=false;if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key)){e.preventDefault();this.camera.azimuth+=e.key==='ArrowLeft'?.07:e.key==='ArrowRight'?-.07:0;this.camera.elevation=Math.max(-.15,Math.min(Math.PI/2-.0001,this.camera.elevation+(e.key==='ArrowUp'?.06:e.key==='ArrowDown'?-.06:0)));this.view='custom';}else if(['+','=','-','_'].includes(e.key)){e.preventDefault();this.camera.distance*=e.key==='-'||e.key==='_'?1.15:.87;}this.request();});
el.addEventListener('webglcontextlost',e=>{e.preventDefault();this.onFrame?.(this,'contextlost');});
el.addEventListener('webglcontextrestored',()=>location.reload());
}
}
+8
View File
@@ -0,0 +1,8 @@
@font-face{font-family:Pretendard;src:url('fonts/PretendardVariable.woff2') format('woff2');font-weight:100 900;font-style:normal;font-display:swap}
.studio-nav{display:flex;align-items:center;gap:4px;flex-shrink:0;margin-left:auto;padding:4px;background:#f3f7f1;border:1px solid #dce6d8;border-radius:8px;font-family:Pretendard,system-ui,sans-serif;white-space:nowrap;letter-spacing:0}
.studio-nav a{padding:8px 10px;border-radius:5px;font-size:10px;line-height:1.3;color:#64816b;text-decoration:none}
.studio-nav a:hover{background:#e8f0e3}.studio-nav a[aria-current=page]{background:#fff;color:#287455;box-shadow:0 1px 4px #294e2f10;font-weight:600}
.studio-nav a:focus-visible{outline:2px solid #287455;outline-offset:2px}
@media(max-width:1200px){.topbar{gap:16px}.topbar .local-badge{display:none}.studio-nav a{padding:8px;font-size:9px}}
@media(max-width:750px){.topbar{height:auto;min-height:100px;display:flex;flex-wrap:wrap;gap:13px;padding:16px 18px}.topbar .brand{min-width:0}.topbar .title-block{margin-left:auto}.studio-nav{flex-basis:100%;justify-content:center;margin-left:0}.studio-nav a{flex:1;text-align:center;padding:9px;font-size:10px}.topbar .title-block h1{font-size:11px;max-width:230px}.topbar .title-block>span{display:none}.app-grid{min-height:calc(100dvh - 164px)}}
@media(max-width:500px){.topbar .title-block{flex-basis:100%;margin:0}.topbar .title-block h1{max-width:none;font-size:12px}}
+14
View File
@@ -0,0 +1,14 @@
@font-face{font-family:Pretendard;src:url('fonts/PretendardVariable.woff2') format('woff2');font-weight:100 900;font-style:normal;font-display:swap}
:root{color-scheme:light;--bg:#f5f7f3;--panel:#fff;--text:#233e36;--muted:#6b7d74;--line:#dfe6df;--accent:#247b64;--soft:#eef5ef;--aqua:#329e9b;--orange:#c78846;--navy:#4a718b;--violet:#876ba7;font-family:Inter,Pretendard,'Noto Sans KR',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-synthesis:none}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-size:13px}button,input,select{font:inherit}button{cursor:pointer;color:inherit}a{color:inherit;text-decoration:none}button:focus-visible,input:focus-visible,select:focus-visible,a:focus-visible,canvas:focus-visible{outline:2px solid var(--accent);outline-offset:3px}button:disabled{opacity:.4;cursor:not-allowed}button{transition:background .15s}button:hover{filter:brightness(.96)}[hidden]{display:none!important}.topbar{height:88px;display:flex;align-items:center;padding:0 28px;gap:35px;border-bottom:1px solid var(--line);background:#fff}.brand{display:flex;gap:12px;align-items:center;font-size:15px;font-weight:700;letter-spacing:1.8px;min-width:232px}.brand small{font-size:7px;letter-spacing:1.7px;display:block;color:var(--muted);font-weight:500;margin-top:5px}.brand-icon{font-size:31px;display:grid;place-items:center;background:#edf6ef;color:var(--accent);width:43px;height:43px;border:1px solid #c9dfce;border-radius:13px;transform:rotate(-5deg)}.title-block{display:flex;flex-direction:column;gap:5px}.title-block h1{font-size:16px;font-weight:600;letter-spacing:-.5px;margin:0}.title-block>span{color:var(--muted);font-size:10px}.local-badge{margin-left:auto;white-space:nowrap;background:var(--soft);border:1px solid #d2e3d3;padding:7px 10px;border-radius:20px;font-size:8px;letter-spacing:1px;color:#497254}.local-badge i,.status-light{display:inline-block;width:5px;height:5px;background:#4c9b6b;border-radius:50%;margin-right:5px}.app-grid{display:grid;grid-template-columns:278px minmax(280px,1fr) 304px;height:calc(100dvh - 122px);min-height:750px}.controls{border-right:1px solid var(--line);padding:28px 23px 20px;overflow:auto;background:#fff;scrollbar-width:thin;scrollbar-color:#c9d8cd transparent}.section-kicker{font-size:8px;letter-spacing:1.8px;color:#6d8878;font-weight:600;display:flex;justify-content:space-between}.section-kicker>span{color:#9aad9e}.controls h2{font-size:23px;line-height:1.6;letter-spacing:-1px;font-weight:600;margin:20px 0 12px}.controls h2 span{color:#348367}.subtle{font-size:11px;color:var(--muted);line-height:1.9;margin:0 0 20px}.summary-cards{display:grid;grid-template-columns:1fr 1fr;border:1px solid var(--line);background:#f7faf6;border-radius:9px;margin-bottom:22px}.summary-cards div{padding:12px 16px}.summary-cards div+div{border-left:1px solid var(--line)}.summary-cards span{font-size:10px;color:var(--muted);display:block;margin-bottom:6px}.summary-cards strong{font-size:25px;font-weight:500;font-variant-numeric:tabular-nums}.controls section{padding:20px 0;border-top:1px solid var(--line)}.section-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:13px}h3{font-size:12px;font-weight:600;margin:0}.small{font-size:9px;color:var(--muted)}.text-button{background:none;border:0;padding:0;font-size:10px;color:#507d64}.search-wrap{display:flex;gap:8px;align-items:center;border:1px solid #ccdbcf;background:#f6f9f5;border-radius:7px;padding:9px 11px}.search-wrap>span{font-size:20px;color:#75917d;line-height:1}input[type=search]{width:100%;background:none;border:0;outline:none;font-size:11px;color:var(--text);padding:3px 0}input::placeholder{color:#859b8c}#search-status{margin-top:7px;min-height:0}.search-results{max-height:225px;overflow:auto;margin-top:6px}.search-result{display:flex;justify-content:space-between;align-items:center;text-align:left;border:0;border-bottom:1px solid var(--line);width:100%;padding:10px 4px;gap:6px;background:white;font-size:10px}.search-result span{overflow-wrap:anywhere}.search-result small{font-size:8px;color:var(--muted);white-space:nowrap}.search-result.missing small{color:#a06b29}.layer-list{display:flex;flex-direction:column;gap:14px}.layer-list label{display:flex;justify-content:space-between;align-items:center;gap:10px;font-size:11px;cursor:pointer}.layer-list label>span{display:flex;align-items:center;gap:8px}.layer-list small{color:#8b9d91;font-size:9px;font-variant-numeric:tabular-nums}.legend-dot{display:inline-block;width:7px;height:7px;border-radius:50%;flex-shrink:0}.junction{background:#4e936e}.conduit{background:var(--aqua);border-radius:2px}.outfall{background:var(--navy)}.other{background:var(--violet)}input[type=checkbox]{accent-color:var(--accent);width:14px;height:14px;margin:0;cursor:pointer}.range-heading{display:flex;justify-content:space-between;font-size:10px;align-items:center;margin-top:16px}.range-heading output{background:#edf5ed;color:#357553;padding:3px 6px;border-radius:4px;font-size:10px;font-variant-numeric:tabular-nums}input[type=range]{width:100%;accent-color:var(--accent);height:4px;margin:15px 0 8pxLine truncated
@media(min-width:1700px){.app-grid{grid-template-columns:300px minmax(400px,1fr) 340px}.controls,.inspector{padding-left:30px;padding-right:30px}.viewport-heading{left:35px;top:35px}}
@media(max-width:1200px){.topbar{gap:20px;padding:0 20px}.brand{min-width:210px}.app-grid{grid-template-columns:245px minmax(280px,1fr) 260px}.controls,.inspector{padding:24px 18px}.controls h2{font-size:21px}.title-block h1{font-size:14px}.viewport-heading{left:20px;right:18px}.scale-badges{left:20px;top:130px}.coordinate-note{left:20px;top:170px}.view-toolbar button{padding:10px 9px;font-size:9px}.legend-panel{left:18px;bottom:130px;padding:10px}.legend-panel small{display:none}.viewer-footer>span{font-size:7px}.viewer-footer{left:16px;right:16px}.viewer-footer i{margin:0 2px}.viewport-heading .section-kicker{font-size:6px}.axes{right:5px}}
@media(max-width:950px){.app-grid{grid-template-columns:245px minmax(280px,1fr);height:auto;min-height:calc(100dvh - 122px)}.viewer{min-height:760px;grid-column:2;grid-row:1}.controls{max-height:900px;grid-column:1;grid-row:1}.inspector{grid-column:1/-1;border-left:0;border-top:1px solid var(--line);padding:28px;min-height:260px}.inspector .section-kicker{margin-bottom:17px}.properties>div{max-width:600px}.profile-wrap{max-width:600px}.title-block>span{display:none}.brand{min-width:195px}.local-badge{font-size:7px;padding:6px}.statusbar{font-size:7px}.viewport-heading .section-kicker{font-size:7px}}
@media(max-width:650px){.topbar{height:auto;min-height:98px;padding:16px 18px;display:grid;grid-template-columns:1fr auto;gap:12px}.brand{font-size:12px;min-width:0;gap:9px}.brand-icon{width:33px;height:33px;font-size:24px;border-radius:10px}.brand small{font-size:6px}.title-block{grid-column:1/-1;grid-row:2}.title-block h1{font-size:13px}.local-badge{grid-column:2;grid-row:1}.app-grid{display:flex;flex-direction:column;min-height:0}.viewer{order:-1;min-height:640px;height:75dvh;max-height:900px;border-bottom:1px solid var(--line)}.controls{padding:24px;overflow:visible;max-height:none;border-right:0}.controls h2{font-size:23px}.controls h2 br{display:none}.controls .subtle br{display:none}.controls section{padding:18px 0}.summary-cards{max-width:400px}.layer-list{display:grid;grid-template-columns:1fr 1fr;gap:18px 25px}.viewport-heading{top:24px}.viewport-heading h2{font-size:24px}.scale-badges{top:116px}.coordinate-note{top:155px}.view-toolbar{bottom:61px}.view-toolbar button{padding:10px 9px;font-size:10px}.viewer-footer>span{font-size:7px}.legend-panel{bottom:130px}.legend-panel small{display:block;font-size:6px}.legend-panel>div{font-size:8px}.axes{bottom:130px}.statusbar{height:auto;min-height:38px;flex-wrap:wrap;padding:10px 18px;font-size:7px}.statusbar>span:last-child{display:none}.audit-summary-grid{grid-template-columns:repeat(2,1fr)}dialog{padding:20px}.dialog-heading h2{font-size:19px}.audit-row{font-size:10px}.help-grid{grid-template-columns:70px 1fr;font-size:11px;gap:14px}.inspector{padding:24px}.search-results{max-height:240px}.datum-note{bottom:112px}}
@media(prefers-reduced-motion:reduce){*{transition:none!important}}
/* Dark 3D workspace; the surrounding controls and pages remain light. */
.viewer{background:radial-gradient(ellipse at 45% 42%,#29383b 0,#1b282e 45%,#121d23 100%);color:#e8ecea}
.viewer .section-kicker{color:#8ca29e}.viewport-heading p{color:#91aaa0}.view-badge{background:#17272bce;border-color:#3d554e;color:#b5cec1}.scale-badges span{background:#1b2d2ce6;border-color:#486255;color:#b3d9c0}.coordinate-note{color:#c2b28f}
.legend-panel{background:#18282be6;border-color:#3b514b}.legend-title{color:#c1d6c9}.legend-panel>div{color:#a9c5b6}.legend-panel>div span{color:#91a79b}.legend-panel small{color:#8aa296}.line-key.circular{background:#64bdb9}.line-key.rectangular{background:#d7b47a}
.axes>span{color:#839d92}.view-toolbar{background:#152126ed;border-color:#3a4a4d;box-shadow:0 8px 30px #0003}.view-toolbar button{color:#a4bcb0}.view-toolbar button:hover{background:#2d403e}.view-toolbar button[aria-pressed=true]{background:#a7e3cb;color:#1b3a2c}.view-toolbar i{background:#354446}
.viewer-footer>span{color:#8aa69b}.viewer .text-button{color:#a5b8b0}.viewer .text-button:hover{color:#a7e3cb}.datum-note{color:#839d92}
+60
View File
@@ -0,0 +1,60 @@
/* Shared typography used by the Hwacheon viewer. */
body { font-family: Pretendard, system-ui, sans-serif; font-size: 15px; line-height: 1.6; }
button, input, select { font-family: inherit; }
.studio-nav a { font-size: 13px; }
.brand small { font-size: 10px; letter-spacing: 1px; }
.title-block h1 { font-size: 17px; }
.title-block > span, .project-label { font-size: 13px; }
:is(.section-kicker, .eyebrow, .micro, .source-label, .small, .range-ends, .scale-note) { font-size: 12px; letter-spacing: .4px; }
:is(.subtle, .intro, .inspector-intro, .empty-detail, .source-note, .source-note p) { font-size: 14px; line-height: 1.8; }
:is(.controls h3, .section-heading h2, .detail-section h3, .notes-content h3) { font-size: 16px; }
:is(.text-button, .panel-toggle, .audit-button, .focus-button, .wide-select, .search-wrap input, .search-results button, .result, .connection-link) { font-size: 14px; }
:is(.layer-list label, .range-heading, .range-heading output, .toggle-label, .toggle-row, .range-label, .select-wrap > span) { font-size: 14px; }
:is(.fine-print, .notice, .profile-caption, .toggle-label small, .connection-link small, .result small, .search-results small, .toggle-row small) { font-size: 12px; line-height: 1.75; }
:is(.properties > div, .overview-list > div, .dimensions dt, .dimensions dd, .notes-content p, .help-list div, .help-grid, .audit-row, .audit-filter-label) { font-size: 14px; line-height: 1.75; }
:is(.dimensions dt span, .notes-content dl div, .audit-summary-grid div, .audit-row button, .audit-filter-label select) { font-size: 12px; }
:is(.summary-cards span, .layer-list small, .type-badge, .view-badge, .viewport-status, .tag, .scale-badges span, .coordinate-note, .legend-title, .legend-panel > div, .legend-panel > div span, .legend-summary, .legend-panel small, .datum-note) { font-size: 12px; }
.legend-panel small { line-height: 1.6; }
:is(.view-toolbar button, .segmented button, .viewer-footer .text-button) { font-size: 14px; }
:is(.viewer-footer, .viewer-footer > span, .statusbar, .bottom-bar, .bottom-bar > span:nth-child(2), .dialog-intro, .dialog-actions > span, #toast) { font-size: 12px; letter-spacing: 0; }
:is(.detail-table, .detail-table th, .detail-table td) { font-size: 12px; }
.controls section { padding-top: 22px; padding-bottom: 18px; }
.layer-list label, .toggle-label, .toggle-row { min-height: 40px; }
.connection-link { flex-wrap: wrap; }
:is(.statusbar, .bottom-bar) { min-height: 38px; height: auto; }
.network-page .viewer-footer { gap: 8px; }
@media (max-width: 1200px) {
.network-page .view-toolbar button { padding: 10px 8px; }
.network-page .viewer-footer > span { max-width: 72%; }
}
@media (max-width: 750px) {
.brand small { font-size: 10px; }
}
@media (max-width: 650px) {
.title-block h1 { font-size: 15px; }
.network-page .layer-list { grid-template-columns: 1fr; gap: 6px; }
.network-page .legend-panel { max-width: calc(100% - 120px); }
.network-page .legend-panel small { font-size: 12px; }
.network-page .viewer-footer > span { font-size: 12px; }
}
/* Nested labels have stronger page-specific rules. */
.network-page .controls > .section-kicker,
.network-page .viewport-heading .section-kicker { font-size: 11px; }
.network-page .viewer .coordinate-note,
.network-page .legend-panel summary,
.network-page .detail-section details summary,
.network-page .inspector table { font-size: 12px; }
.network-page .inspector .properties > div { font-size: 14px; }
.network-page .viewport-heading p { font-size: 13px; }
.network-page .topbar .title-block h1 { font-size: 17px; }
.network-page .legend-panel > div { font-size: 12px; }
.network-page .scale-badges span { font-size: 12px; }
@media (max-width: 650px) {
.network-page .topbar .title-block h1 { font-size: 15px; }
}
@media (max-width: 650px) {
.network-page .legend-panel small { display: none; }
.network-page .legend-panel > div { margin-top: 6px; }
}