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.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 drawingFormats=root.querySelector<HTMLInputElement>('#landing-drawing-longitudinal-input')?.accept||'.dwg,.dxf,.pdf,.png,.jpg,.jpeg';
const titles=['시작 방식 선택','프로젝트 데이터 준비','프로젝트 시작'];
const descriptions: Record<StartMode,string[]>={
excel:['작성한 교각 설계정보가 있다면 엑셀 불러오기를 선택하세요. 원본 시트의 인식 결과를 확인하며 시작할 수 있습니다.',
'프로젝트명을 입력하고 Excel 파일을 선택하세요. 인식된 시트와 교각을 확인한 뒤 사용할 항목을 고릅니다.',
'가져오기 창에서 선택한 교각의 형상 생성 버튼을 누르면 기존 3D 형상 화면으로 이동합니다. 이후 기능은 프로그램에서 제공하는 범위 안에서 사용할 수 있습니다.'],
drawing:['도면 파일을 준비했다면 도면 불러오기를 선택하세요. 현재는 도면 선택과 기존 화면으로의 이동 흐름을 제공합니다.',
'종평면도·교량받침배치도·ELEVATION 산출근거도를 각각 선택합니다. 세 파일을 모두 고르면 다음 버튼이 활성화됩니다.',
'세 파일을 선택한 뒤 도면 분석 시작 버튼으로 기존 프로그램 화면에 진입합니다. 자동 도면 분석·제원 추출 기능은 아직 이 화면에 연결되지 않았습니다.'],
sample:['파일 준비 없이 실제 교각과 지반 모델을 살펴보려면 샘플 프로젝트를 선택하세요.',
'샘플 선택 패널에서 산외교·운곡교 등 12개 교량의 이름과 교각 수를 확인하고 원하는 교량을 선택합니다.',
`현재 ${sampleProjects.filter(s=>!s.blockReason).length}개 교량은 교각과 원도면 지반면, 위성사진, 2D 지도를 함께 엽니다. 확인이 필요한 샘플은 이유를 안내합니다. 설계 지반고는 Excel 원래 값을 유지하며, 기존 검증 알림은 확인이 필요합니다.`]
};
const facts: Record<StartMode,string[]>={
excel:['선택 가이드 · 교각 설계정보가 담긴 Excel 파일이 있을 때',`현재 주 가져오기 창의 지원 확장자: ${excelFormats.replaceAll(',', ' · ')}`,'확인할 내용 · 시트 인식 결과, 선택 교각, 프로젝트명'],
drawing:['지원 범위 · 파일 선택 흐름 제공 / 자동 분석 연결 준비 중',`현재 파일 선택 확장자: ${drawingFormats.replaceAll(',', ' · ')}`,'안내 · 구조해석·자동설계가 완료된 기능이라는 의미는 아닙니다.'],
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);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};
}