Add standalone Hwacheon network viewer
This commit is contained in:
commit
d64b820ae2
15 files changed
+748
No files matched your search
+51
@@ -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/
|
||||
@@ -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 이슈로 관리합니다.
|
||||
@@ -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())
|
||||
@@ -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
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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();
|
||||
@@ -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
|
||||
@@ -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.
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user