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

+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; }
}