126 lines
11 KiB
TypeScript
126 lines
11 KiB
TypeScript
import { sampleProjects } from './sample-projects';
|
||
type StartMode = 'excel' | 'drawing' | 'sample';
|
||
const modeNames: Record<StartMode,string> = {excel:'엑셀 불러오기',drawing:'도면 불러오기',sample:'기설계 교량'};
|
||
|
||
/** A native dialog keeps keyboard focus and upload dialogs from competing. */
|
||
export function createLandingGuide(root: HTMLElement): {close:(restoreFocus?:boolean)=>void;dismissHint:()=>void} {
|
||
const trigger=root.querySelector<HTMLButtonElement>('.landing-guide-trigger')!;
|
||
const hero=root.querySelector<HTMLElement>('.landing-hero')!;
|
||
const hint=document.createElement('aside');
|
||
hint.className='landing-guide-hint';hint.setAttribute('aria-label','처음 사용자 안내');
|
||
hint.innerHTML=`<span class="landing-hint-icon" aria-hidden="true">✦</span><div><strong>처음 사용하시나요?</strong><p>사용 가이드로 시작해 보세요.</p><button type="button" class="landing-hint-open">사용 가이드 열기 <span aria-hidden="true">→</span></button></div><button type="button" class="landing-hint-close" aria-label="처음 사용자 안내 닫기">×</button>`;
|
||
const hintKey='pierzainer:landing:guide-hint-dismissed';
|
||
try {hint.hidden=sessionStorage.getItem(hintKey)==='1';} catch { /* In-memory dismissal still works without browser storage. */ }
|
||
hero.append(hint);
|
||
const dismissHint=():void=>{hint.hidden=true;try{sessionStorage.setItem(hintKey,'1');}catch{/* Storage may be blocked. */}};
|
||
const dialog=document.createElement('dialog');
|
||
dialog.className='landing-guide';dialog.id='landing-guide';dialog.setAttribute('aria-labelledby','landing-guide-title');
|
||
dialog.innerHTML=`
|
||
<div class="landing-guide-head"><span class="landing-guide-kicker">GET STARTED / PIER & ABUT</span><button type="button" class="landing-guide-close" aria-label="사용 가이드 닫기" autofocus>×</button></div>
|
||
<h2 id="landing-guide-title">하나의 시작, 세 가지 방법</h2>
|
||
<p class="landing-guide-intro">입력 카드와 우측 샘플 패널은 서로 다른 시작 방식입니다.<br>준비된 자료에 맞는 시작 방식 하나를 선택하세요.</p>
|
||
<div class="landing-guide-modes" role="group" aria-label="안내할 시작 방식">
|
||
${(['excel','drawing','sample'] as StartMode[]).map(mode=>`<button type="button" data-guide-mode="${mode}" aria-pressed="false">${modeNames[mode]}</button>`).join('')}
|
||
</div>
|
||
<div class="landing-guide-progress" role="group" aria-label="사용 가이드 단계">
|
||
<button type="button" data-guide-step="0" aria-label="1단계 시작 방식 선택">01<span>방식 선택</span></button>
|
||
<button type="button" data-guide-step="1" aria-label="2단계 프로젝트 데이터 준비">02<span>자료 준비</span></button>
|
||
<button type="button" data-guide-step="2" aria-label="3단계 프로젝트 시작">03<span>프로젝트 시작</span></button>
|
||
</div>
|
||
<div class="landing-guide-content" aria-live="polite" aria-atomic="true">
|
||
<div class="landing-guide-illustration" aria-hidden="true"><span class="landing-guide-icon"></span><i></i><i></i><i></i><span class="landing-guide-illustration-label"></span></div>
|
||
<div class="landing-guide-step-label"></div><h3 class="landing-guide-step-title"></h3><p class="landing-guide-step-description"></p><div class="landing-guide-fact"></div>
|
||
</div>
|
||
<div class="landing-guide-navigation"><button type="button" class="landing-guide-prev">← 이전</button><span class="landing-guide-page"></span><button type="button" class="landing-guide-next">다음 →</button></div>`;
|
||
root.append(dialog);
|
||
trigger.setAttribute('aria-controls',dialog.id);
|
||
trigger.setAttribute('aria-expanded','false');
|
||
let mode: StartMode='excel';
|
||
let step=0;
|
||
let opener: HTMLElement=trigger;
|
||
const highlight=():void=>{
|
||
root.querySelector<HTMLElement>('[data-guide-target=sample]')?.classList.toggle('is-guided',dialog.open&&mode==='sample');
|
||
root.querySelectorAll<HTMLElement>('[data-card]').forEach(card=>card.classList.toggle('is-guided',dialog.open&&card.dataset.card===mode));
|
||
};
|
||
const setText=(selector:string,text:string):void=>{dialog.querySelector<HTMLElement>(selector)!.textContent=text;};
|
||
const render=():void=>{
|
||
const excelFormats=document.querySelector<HTMLInputElement>('#file')?.accept||'.xlsx';
|
||
const titles=['시작 방식 선택','프로젝트 데이터 준비','프로젝트 시작'];
|
||
const descriptions: Record<StartMode,string[]>={
|
||
excel:['작성한 교각 설계정보가 있다면 엑셀 불러오기를 선택하세요. 원본 시트의 인식 결과를 확인하며 시작할 수 있습니다.',
|
||
'프로젝트명을 입력하고 Excel 파일을 선택하세요. 인식된 시트와 교각을 확인한 뒤 사용할 항목을 고릅니다.',
|
||
'가져오기 창에서 선택한 교각의 형상 생성 버튼을 누르면 기존 3D 형상 화면으로 이동합니다. 이후 기능은 프로그램에서 제공하는 범위 안에서 사용할 수 있습니다.'],
|
||
drawing:['도면 불러오기를 누르면 프로그램 안에 도면추출 팝업이 열립니다.',
|
||
'종평면도·받침배치도·ELEVATION DWG가 들어 있는 폴더를 선택하고 자동 추출 시작을 누르세요. 진행 내용이 팝업에 표시됩니다.',
|
||
'결과 목록의 교각 불러오기를 눌러 Excel 인식 결과를 확인하고 형상을 생성합니다. Excel 저장으로 파일만 받을 수도 있습니다.'],
|
||
sample:['파일 준비 없이 실제 교각과 지반 모델을 살펴보려면 샘플 프로젝트를 선택하세요.',
|
||
'설계 라이브러리 팝업에서 산외교·운곡교 등 12개 교량의 이름과 교각 수를 확인하고 원하는 교량을 선택합니다.',
|
||
`현재 ${sampleProjects.filter(s=>s.pierCount>0).length}개 교량은 교각과 원도면 지반면, 위성사진, 2D 지도를 함께 엽니다. 교각 데이터가 없는 승천천교는 지반·지도 자료를 표시합니다. 설계 지반고는 Excel 원래 값을 유지하며, 기존 검증 알림은 확인이 필요합니다.`]
|
||
};
|
||
const facts: Record<StartMode,string[]>={
|
||
excel:['선택 가이드 · 교각 설계정보가 담긴 Excel 파일이 있을 때',`현재 주 가져오기 창의 지원 확장자: ${excelFormats.replaceAll(',', ' · ')}`,'확인할 내용 · 시트 인식 결과, 선택 교각, 프로젝트명'],
|
||
drawing:['실행 방식 · 프로그램 내부 팝업 / 기존 추출 엔진','입력 · DWG가 들어 있는 폴더 / AI 연결 없음','결과 · 프로젝트의 도면추출/output에 저장 / 미확정 항목은 검토 필요'],
|
||
sample:['시작 방식 · 실제 교량 12개 중 선택','포함 자료 · 교각 + 지반면 + 위성사진 + 2D 지도','확인 사항 · 원본 CRS 확인이 필요한 추정 좌표계 포함']
|
||
};
|
||
setText('.landing-guide-step-label',`STEP 0${step+1} / ${modeNames[mode]}`);
|
||
setText('.landing-guide-step-title',titles[step]);
|
||
setText('.landing-guide-step-description',descriptions[mode][step]);
|
||
setText('.landing-guide-fact',facts[mode][step]);
|
||
setText('.landing-guide-page',`${step+1} / 3`);
|
||
setText('.landing-guide-illustration-label',['CHOOSE YOUR START','PREPARE YOUR INPUT','OPEN YOUR PROJECT'][step]);
|
||
const icon=root.querySelector(`[data-card="${mode}"] .landing-card-icon svg`);
|
||
dialog.querySelector('.landing-guide-icon')!.replaceChildren(...(icon?[icon.cloneNode(true)]:[]));
|
||
dialog.dataset.step=String(step);
|
||
dialog.querySelector<HTMLButtonElement>('.landing-guide-prev')!.disabled=step===0;
|
||
setText('.landing-guide-next',step===2?`${modeNames[mode]}로 시작`:'다음 →');
|
||
dialog.querySelectorAll<HTMLButtonElement>('[data-guide-mode]').forEach(button=>button.setAttribute('aria-pressed',String(button.dataset.guideMode===mode)));
|
||
dialog.querySelectorAll<HTMLButtonElement>('[data-guide-step]').forEach(button=>{
|
||
if(Number(button.dataset.guideStep)===step)button.setAttribute('aria-current','step');else button.removeAttribute('aria-current');
|
||
});
|
||
highlight();
|
||
};
|
||
const finishClose=():void=>{
|
||
root.classList.remove('guide-open');trigger.setAttribute('aria-expanded','false');highlight();
|
||
};
|
||
const close=(restoreFocus=true):void=>{
|
||
if(!dialog.open)return;
|
||
dialog.close();finishClose();
|
||
if(restoreFocus&&opener.isConnected&&!root.hidden)opener.focus();
|
||
};
|
||
const open=(source:HTMLElement):void=>{
|
||
if(root.hidden||document.querySelector('#import-dialog[open]')||root.querySelector('.landing-panel.visible'))return;
|
||
dismissHint();opener=source;step=0;
|
||
if(!dialog.open)dialog.showModal();
|
||
root.classList.add('guide-open');trigger.setAttribute('aria-expanded','true');render();
|
||
};
|
||
trigger.addEventListener('click',()=>open(trigger));
|
||
hint.querySelector<HTMLButtonElement>('.landing-hint-open')!.addEventListener('click',()=>open(trigger));
|
||
hint.querySelector<HTMLButtonElement>('.landing-hint-close')!.addEventListener('click',()=>{dismissHint();trigger.focus();});
|
||
dialog.querySelector('.landing-guide-close')!.addEventListener('click',()=>close());
|
||
dialog.addEventListener('close',finishClose);
|
||
dialog.addEventListener('cancel',(event)=>{event.preventDefault();close();});
|
||
dialog.addEventListener('keydown',(event)=>{
|
||
if(event.key!=='Tab')return;
|
||
const buttons=Array.from(dialog.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')).filter(button=>button.getClientRects().length>0);
|
||
const first=buttons[0],last=buttons[buttons.length-1];
|
||
if(!first||!last)return;
|
||
if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus();}
|
||
else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();}
|
||
});
|
||
dialog.addEventListener('click',(event)=>{
|
||
if(event.target!==dialog)return;
|
||
const bounds=dialog.getBoundingClientRect();
|
||
if(event.clientX<bounds.left||event.clientX>bounds.right||event.clientY<bounds.top||event.clientY>bounds.bottom)close();
|
||
});
|
||
dialog.querySelectorAll<HTMLButtonElement>('[data-guide-mode]').forEach(button=>button.addEventListener('click',()=>{mode=button.dataset.guideMode as StartMode;render();}));
|
||
dialog.querySelectorAll<HTMLButtonElement>('[data-guide-step]').forEach(button=>button.addEventListener('click',()=>{step=Number(button.dataset.guideStep);render();}));
|
||
dialog.querySelector('.landing-guide-prev')!.addEventListener('click',()=>{step=Math.max(0,step-1);render();if(step===0)dialog.querySelector<HTMLButtonElement>('[data-guide-step="0"]')!.focus();});
|
||
dialog.querySelector('.landing-guide-next')!.addEventListener('click',()=>{
|
||
if(step<2){step++;render();return;}
|
||
close(false);if(mode==='sample'){root.querySelector<HTMLButtonElement>('[data-card=sample] button')?.click();return;}root.querySelector<HTMLButtonElement>(`[data-card="${mode}"] button`)?.click();
|
||
});
|
||
// Main-app functions may hide this overlay without calling landing functions.
|
||
new MutationObserver(()=>{if(root.hidden)close(false);}).observe(root,{attributes:true,attributeFilter:['hidden']});
|
||
return {close,dismissHint};
|
||
}
|