Files
hwacheon-network-viewer/web/app.js

172 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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&&window.innerWidth<=1000)setPanel('inspector',true);
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 reviewReasons=app.network.issues.filter(i=>['length_mismatch','adverse_slope','missing_coordinate','unplaced_link','coincident_endpoints'].includes(i.code)&&i.ids.includes(f.id));
if(reviewReasons.length)html+=section('점검 필요 · 오류 확정 아님',`<ul class="review-reasons">${reviewReasons.map(i=>`<li>${esc(i.message)}</li>`).join('')}</ul>`);
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 setPanel(panel,open){
const narrow=window.innerWidth<=1000;
if(narrow&&open)document.body.classList.add(panel==='controls'?'hide-inspector':'hide-controls');
document.body.classList.toggle(`hide-${panel}`,!open);
for(const name of ['controls','inspector']){
const visible=!document.body.classList.contains(`hide-${name}`);
$(`#toggle-${name}`).setAttribute('aria-expanded',String(visible));
$(`#${name}-panel`).inert=!visible;
}
$('#panel-backdrop').hidden=!narrow||['controls','inspector'].every(n=>document.body.classList.contains(`hide-${n}`));
}
function initPanels(){
const narrow=matchMedia('(max-width: 1000px)');
const layout=()=>{setPanel('controls',!narrow.matches);setPanel('inspector',!narrow.matches);};
layout();narrow.addEventListener('change',layout);
for(const name of ['controls','inspector'])$(`#toggle-${name}`).onclick=()=>setPanel(name,document.body.classList.contains(`hide-${name}`));
$('#panel-backdrop').onclick=()=>{setPanel('controls',false);setPanel('inspector',false);};
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&narrow.matches&&!$('dialog[open]')){
const wasControls=!document.body.classList.contains('hide-controls');
setPanel('controls',false);setPanel('inspector',false);
$(`#toggle-${wasControls?'controls':'inspector'}`).focus();
}});
}
function syncAuditHighlight(){
const enabled=$('#audit-highlight').checked,reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
const steady=$('#audit-steady').checked||reduced;
$('#audit-steady').disabled=!enabled||reduced;
$('#audit-highlight-note').textContent=reduced?'동작 줄이기 설정에 따라 고정 강조합니다. 오류 확정이 아닌 점검 필요 표시입니다.':steady?'고정 빨간색으로 강조합니다. 오류 확정이 아닌 점검 필요 표시입니다.':'빨간색 1초 · 원래 색 1초 반복. 오류 확정이 아닌 점검 필요 표시입니다.';
app.renderer.setAuditHighlight(enabled,steady);
}
function reset(){
$('#audit-highlight').checked=false;$('#audit-steady').checked=false;syncAuditHighlight();
$('#height-scale').value=3;$('#size-scale').value=3;$('#section-mode').checked=false;$$('[data-layer]').forEach(b=>{b.checked=true;app.renderer.setLayer(b.dataset.layer,true);});updateScales();app.renderer.set({selected:0});app.selected=null;$('#search').value='';updateSearch();clearDetails();$('#view-name').textContent='전체 관망';app.renderer.setView('iso');app.renderer.fit();toast('표시·배율·시점을 초기화했습니다.');
}
function frame(renderer,error){
if(error==='contextlost'){fatal('그래픽 연결이 중단되었습니다. 페이지를 새로고침해 주세요.');return;}
const visible=app.facilities.filter(f=>renderer.visible(f));const nodes=visible.filter(f=>f.xy).length,conduits=visible.filter(f=>f.type==='conduit').length,other=visible.filter(f=>['pump','weir'].includes(f.type)).length;
$('#visible-count').textContent=`표시 노드 ${nodes} · 관로 ${conduits} · 펌프·위어 ${other}`;
const names={iso:'입체 보기',plan:'평면 보기',side:'측면 보기',custom:'사용자 시점'};$('#view-badge').textContent=names[renderer.view];$$('[data-view]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.view===renderer.view)));
$('#render-status').textContent=`로컬 모델 · 표시 시설 ${visible.length}`;
$('#model-status').textContent=`수리 제원 m · 원점 X ${fmt(app.network.meta.origin[0],1)} / Y ${fmt(app.network.meta.origin[1],1)}`;
}
function openAudit(filter='placement'){$('#audit-filter').value=filter;updateAudit();$('#audit-dialog').showModal();}
function updateAudit(){
const filter=$('#audit-filter').value,issues=app.network.issues.filter(i=>filter==='all'||(filter==='placement'?['missing_coordinate','unplaced_link','coincident_endpoints'].includes(i.code):i.code===filter));
$('#audit-list').innerHTML=issues.length?issues.map(i=>`<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(){
initPanels();
try{
const response=await fetch('data/network.json',{cache:'no-store'});if(!response.ok)throw Error('관망 데이터를 읽을 수 없습니다. web/data/network.json 파일이 포함되어 있는지 확인해 주세요.');
app.network=await response.json();const n=app.network,s=n.summary;
app.facilities=[...n.nodes,...n.links].map((f,i)=>({...f,pickId:i+1}));
app.renderer=new NetworkRenderer($('#scene'),n,app.facilities,selectFacility,frame);
const unplaced=app.facilities.filter(f=>!isPlaced(f));
$('#audit-highlight-count').textContent=`표시 가능한 점검 관로 ${app.renderer.auditPickIds.size}개`;
$('#audit-unplaced').textContent=`위치 표시 불가 ${unplaced.length}개 · 사유 보기 ↗`;
$('#audit-unplaced').onclick=()=>openAudit('placement');
$('#audit-highlight').onchange=syncAuditHighlight;$('#audit-steady').onchange=syncAuditHighlight;
matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change',syncAuditHighlight);syncAuditHighlight();
$('#node-count').textContent=s.counts.junction;$('#pipe-count').textContent=s.counts.conduit;
for(const [layer,count] of Object.entries({junction:s.counts.junction,conduit:s.counts.conduit,outfall:s.counts.outfall,other:s.counts.storage+s.counts.pump+s.counts.weir}))$(`#layer-${layer}-count`).textContent=count;
$('#circle-count').textContent=s.shapes.CIRCULAR;$('#rect-count').textContent=s.shapes.RECT_CLOSED;$('#source-name').textContent=n.meta.sourceName;
$('#audit-count').textContent=`배치 점검 ${n.issues.filter(i=>['missing_coordinate','unplaced_link','coincident_endpoints'].includes(i.code)).length}`;
$('#audit-summary').innerHTML=`<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();