Files
Pier_v1/랜딩페이지/landing-guide.ts
T

126 lines
11 KiB
TypeScript
Raw 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 { 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};
}