Files
Pier_v1/랜딩페이지/landing-page.ts
T
2026-11-03 12:04:58 +09:00

374 lines
17 KiB
TypeScript

import { createLandingGuide } from './landing-guide';
import { createHeroMotion, heroMotionMarkup } from './landing-motion';
import './landing-white.css';
import { sampleProjects } from './sample-projects';
import { parseProject } from '../src/core/project';
import { createDrawingPopup, drawingPopupMarkup } from './drawing-popup';
const appRoot = document.querySelector<HTMLDivElement>('.app');
const homeButton = document.querySelector<HTMLButtonElement>('#home-from-app');
const promoImageUrl = new URL('./assets/pierzainer-terrain-promo.png', import.meta.url).href;
const companyLogosUrl = new URL('./assets/family-company-logos.png', import.meta.url).href;
const landingOverlay = document.createElement('div');
landingOverlay.className = 'landing-overlay landing-white';
landingOverlay.setAttribute('role', 'dialog');
landingOverlay.setAttribute('aria-modal', 'true');
landingOverlay.setAttribute('aria-label', '프로그램 시작 선택');
const landingPanelMarkup = `
<div class="landing-shell">
<section class="landing-hero" aria-label="프로그램 홍보 영역">
<div class="landing-engineering-grid" aria-hidden="true"></div>
<div class="landing-light-stage" aria-hidden="true">
<div class="landing-glow"></div>
<div class="landing-light-ring"></div>
<div class="landing-orbit landing-orbit-one"></div>
<div class="landing-orbit landing-orbit-two"></div>
<div class="landing-orbit landing-orbit-three"></div>
<div class="landing-particles">
<i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
</div>
</div>
<div class="landing-hero-copy">
<div class="landing-title-group">
<h1 class="landing-title">PIER <span>&amp;</span> ABUT</h1>
<h2 class="landing-headline">교각 설계의 새로운 시작</h2>
</div>
<div class="landing-brand-meta">
<span class="landing-kicker"><span class="landing-status-light"></span> PIERZAINER <span class="landing-kicker-divider">/</span><span class="landing-kicker-detail">BRIDGE DESIGN STUDIO</span></span>
<p class="landing-subtitle">
설계정보 입력부터 3D 모델링, 구조해석, 철근배근, 도면 및 수량산출까지 하나의 플랫폼에서
</p>
<button class="landing-guide-trigger" type="button" aria-haspopup="dialog"><span aria-hidden="true">?</span>사용 가이드 <span class="landing-guide-trigger-arrow" aria-hidden="true">↗</span></button>
</div>
</div>
<div class="landing-hero-visual" aria-hidden="true">
<div class="landing-visual">
<div class="landing-artwork">
<img class="landing-promo-image" src="${promoImageUrl}" alt="" draggable="false" fetchpriority="high" />
${heroMotionMarkup(promoImageUrl)}
</div>
</div>
<span class="landing-showcase-label">DIGITAL TWIN SHOWCASE <span>브랜딩 모션 · 해석 결과 아님</span></span>
</div>
<section class="landing-cards" aria-label="입력 방식 선택">
<article class="landing-card" data-card="excel">
<div>
<div class="landing-card-top"><div class="landing-card-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6M8 13h8M8 17h8M12 13v4"/></svg></div><span class="landing-card-number">01 / IMPORT</span></div>
<span class="landing-card-eyebrow">Excel Import</span>
<h3>엑셀 불러오기</h3>
<p>기존에 작성된 설계정보 Excel 파일을 불러와 모델링을 시작합니다.</p>
</div>
<div class="landing-card-actions">
<span class="landing-card-note">Excel · .xlsx</span>
<button class="landing-primary" type="button">Excel 파일 불러오기</button>
</div>
</article>
<article class="landing-card" data-card="drawing">
<div>
<div class="landing-card-top"><div class="landing-card-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4h16v16H4zM4 9h7v11M11 4v5h9M15 13h2v3h-2M7 13v3"/></svg></div><span class="landing-card-number">02 / EXTRACT</span></div>
<span class="landing-card-eyebrow">Drawing Import</span>
<h3>도면 불러오기</h3>
<p>도면 폴더에서 교각 정보를 추출하고 결과 Excel을 바로 불러옵니다.</p>
</div>
<div class="landing-card-actions">
<span class="landing-card-note" id="landing-drawing-status" role="status" aria-live="polite">프로그램 안에서 도면 추출</span>
<button class="landing-primary" type="button">도면 불러오기</button>
</div>
</article>
<article class="landing-card" data-card="sample">
<div>
<div class="landing-card-top"><div class="landing-card-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 3 9 5-9 5-9-5zM3 8v9l9 5 9-5V8M12 13v9"/></svg></div><span class="landing-card-number">03 / EXPLORE</span></div>
<span class="landing-card-eyebrow">Explore Sample</span>
<h3>샘플 프로젝트</h3>
<p>실제 교량의 교각·지반·위성사진·2D 지도를 함께 살펴봅니다.</p>
</div>
<div class="landing-card-actions">
<span class="landing-card-note">자료 12개 · 바로 열기 ${sampleProjects.filter(s => !s.blockReason).length}개</span>
<button class="landing-primary" type="button">샘플 모델 선택</button>
</div>
</article>
</section>
<footer class="landing-footer">
<div class="landing-footer-note" aria-hidden="true"><span>PRECISION IN EVERY DIMENSION</span><span>PIERZAINER · PIER &amp; ABUT</span></div>
<div class="landing-company-logos"><img src="${companyLogosUrl}" width="9719" height="295" alt="한맥기술, 삼안, 장헌산업, 피티씨, 장헌, 한라산업개발, 바로컨설턴트 로고" draggable="false" /></div>
</footer>
</section>
<section id="landing-excel-panel" class="landing-panel" aria-label="엑셀 입력 패널">
<div class="landing-panel-head">
<h3>엑셀 입력</h3>
<button type="button" class="landing-close-button" data-close-panel="landing-excel-panel">닫기</button>
</div>
<div class="landing-panel-body">
<div class="landing-file-meta">
<span>선택 파일</span>
<strong id="landing-excel-name">선택된 파일이 없습니다.</strong>
</div>
<div class="landing-actions-row">
<input id="landing-excel-input" type="file" accept=".xlsx,.xls,.xlsm" hidden />
<button class="landing-ghost" type="button" data-pick-file="excel">파일 선택</button>
<button id="landing-excel-start" class="landing-primary" type="button" disabled>모델링 시작</button>
</div>
</div>
</section>
${drawingPopupMarkup}
<section id="landing-sample-panel" class="landing-panel" aria-label="샘플 모델 패널">
<div class="landing-panel-head">
<h3>교각 + 지반 샘플 프로젝트</h3>
<button type="button" class="landing-close-button" data-close-panel="landing-sample-panel">닫기</button>
</div>
<div class="landing-panel-body">
<p class="landing-sample-intro">검증된 교량은 교각·지반·위성사진·2D 지도를 함께 엽니다. 확인이 필요한 샘플은 원인을 안내합니다. Excel 설계 지반고는 유지합니다.</p>
<p class="landing-sample-status" id="landing-sample-status" role="status" aria-live="polite"></p>
<div class="landing-sample-grid" id="landing-sample-grid"></div>
<p class="landing-sample-caption">지도 좌표계는 원자료 확인이 필요한 추정값을 포함합니다. 지반면과 설계 지반고의 차이 및 기존 모델 검증 알림은 보존됩니다.</p>
</div>
</section>
</div>
`;
landingOverlay.innerHTML = landingPanelMarkup;
document.body.prepend(landingOverlay);
const landingGuide = createLandingGuide(landingOverlay);
createHeroMotion(landingOverlay);
const selectPanel = (panelId: string): void => {
landingGuide.close(false);
document.querySelectorAll<HTMLElement>('.landing-panel').forEach((panel) => {
panel.classList.toggle('visible', panel.id === panelId);
});
const visible = document.getElementById(panelId);
if (visible) {
visible.scrollTo({ top: 0, behavior: 'auto' });
}
};
const closePanels = (): void => {
document.querySelectorAll<HTMLElement>('.landing-panel').forEach((panel) => panel.classList.remove('visible'));
};
const showApp = (): void => {
landingGuide.close(false);
if (appRoot) {
appRoot.hidden = false;
appRoot.setAttribute('aria-hidden', 'false');
}
if (homeButton) {
homeButton.hidden = false;
}
landingOverlay.hidden = true;
};
const showLanding = (): void => {
if (appRoot) {
appRoot.hidden = true;
appRoot.setAttribute('aria-hidden', 'true');
}
if (homeButton) {
homeButton.hidden = true;
}
landingOverlay.hidden = false;
landingOverlay.classList.remove('modal-open');
closePanels();
};
const selectFile = (input: HTMLInputElement): void => {
input.click();
};
const excelInput = document.querySelector<HTMLInputElement>('#landing-excel-input');
const excelName = document.querySelector<HTMLElement>('#landing-excel-name');
const excelStart = document.querySelector<HTMLButtonElement>('#landing-excel-start');
const ensureNoLegacyImportDialog = (): void => {
const dialog = document.querySelector<HTMLDialogElement>('#import-dialog');
if (dialog && dialog.open) dialog.close();
};
if (excelInput && excelName && excelStart) {
excelInput.addEventListener('change', () => {
const file = excelInput.files?.[0];
const name = file ? file.name : '선택된 파일이 없습니다.';
excelName.textContent = name;
excelStart.disabled = !file;
ensureNoLegacyImportDialog();
});
excelStart.addEventListener('click', () => {
if (!excelInput.files?.[0]) return;
ensureNoLegacyImportDialog();
showApp();
});
}
const bindFilePicker = (button: Element, targetId: string, nameTargetId: string): void => {
const input = document.querySelector<HTMLInputElement>(`#${targetId}`);
const output = document.querySelector<HTMLElement>(`#${nameTargetId}`);
if (!input || !output) return;
button.addEventListener('click', () => selectFile(input));
input.addEventListener('change', () => {
const file = input.files?.[0];
output.textContent = file ? file.name : '미선택';
});
};
const bindCard = (cardSelector: string, panelId: string, mode: 'panel' | 'picker' = 'panel'): void => {
const card = document.querySelector<HTMLElement>(cardSelector);
const trigger = card?.querySelector<HTMLButtonElement>('button');
if (!trigger) return;
trigger.addEventListener('click', () => {
landingGuide.dismissHint();
landingGuide.close(false);
if (mode === 'picker') {
if (cardSelector === '[data-card="excel"]') {
if (landingOverlay) {
landingOverlay.hidden = false;
landingOverlay.classList.add('modal-open');
}
const dialog = document.querySelector<HTMLDialogElement>('#import-dialog');
if (dialog && !dialog.open) {
dialog.showModal();
}
const projectName = document.querySelector<HTMLInputElement>('#project-name');
if (projectName) projectName.focus();
return;
}
if (cardSelector === '[data-card="sample"]') {
void window.openSampleProject?.();
return;
}
}
selectPanel(panelId);
});
};
bindCard('[data-card="excel"]', 'landing-excel-panel', 'picker');
bindCard('[data-card="sample"]', 'landing-sample-panel');
const drawingPopup = createDrawingPopup(landingOverlay, () => {
landingGuide.dismissHint();
landingGuide.close(false);
closePanels();
});
document.querySelector<HTMLButtonElement>('[data-card="drawing"] button')!.addEventListener('click', () => drawingPopup.open());
document.querySelectorAll<HTMLElement>('[data-close-panel]').forEach((button) => {
button.addEventListener('click', () => {
const panelId = button.getAttribute('data-close-panel');
if (!panelId) return;
const panel = document.getElementById(panelId);
panel?.classList.remove('visible');
});
});
document.querySelectorAll<HTMLButtonElement>('[data-pick-file]').forEach((button) => {
const kind = button.getAttribute('data-pick-file');
if (!kind) return;
const targetMap: Record<string, string> = {
excel: 'landing-excel-input',
};
const targetId = targetMap[kind];
if (!targetId) return;
const input = document.querySelector<HTMLInputElement>(`#${targetId}`);
if (!input) return;
button.addEventListener('click', () => selectFile(input));
});
bindFilePicker(document.querySelector('[data-pick-file="excel"]') as HTMLButtonElement, 'landing-excel-input', 'landing-excel-name');
const sampleGrid = document.querySelector<HTMLElement>('#landing-sample-grid');
let sampleLoading = false;
if (sampleGrid) {
sampleProjects.forEach((sample) => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'landing-sample';
item.innerHTML = `
<span class="landing-sample-badge">${String(sample.sample).padStart(2, '0')}</span>
<strong>${sample.bridge}</strong>
<span>교각 ${sample.sourcePierCount}기 · 지반 + 지도 2종</span>
<span class="landing-sample-open">${sample.blockReason ? (sample.pierCount ? '앱 호환 오류 확인' : '방위각 입력 확인 필요') : `형상 ${sample.builtCount}/${sample.sourcePierCount}기 · 열기 ↗`}</span>
`;
item.dataset.sample = String(sample.sample);
item.addEventListener('click', async () => {
if (sampleLoading) return;
const status = document.querySelector<HTMLElement>('#landing-sample-status')!;
if (sample.blockReason) { status.textContent = `${sample.bridge}: ${sample.blockReason}`; return; }
sampleLoading = true;
status.textContent = `${sample.bridge} · 교각과 지반·지도를 불러오는 중…`;
sampleGrid.setAttribute('aria-busy', 'true');
sampleGrid.querySelectorAll('button').forEach(button => { button.disabled = true; });
try {
const response = await fetch(sample.url);
if (!response.ok) throw new Error(`샘플 다운로드 실패 (${response.status})`);
const text = await response.text();
const project = parseProject(text);
if (project.project.bridge !== sample.bridge || project.piers.length !== sample.pierCount || project.groundModel?.mapLayers?.length !== 2) {
throw new Error('샘플의 교각 또는 지도 데이터가 일치하지 않습니다.');
}
const picker = document.querySelector<HTMLInputElement>('#project-file');
const toasts = document.querySelector('#toast-host');
if (!picker || !toasts) throw new Error('프로젝트 열기 기능을 찾을 수 없습니다.');
// Reuse the app's existing project-file import. No parallel model loader or global state edits.
const opened = new Promise<void>((resolve, reject) => {
const observer = new MutationObserver(records => {
for (const record of records) for (const node of record.addedNodes) {
const message = node.textContent ?? '';
if (message.includes(`${sample.filename}을 열었습니다.`)) { cleanup(); resolve(); }
else if (message.includes('프로젝트 파일을 열지 못했습니다:')) { cleanup(); reject(new Error(message)); }
}
});
const timer = window.setTimeout(() => { cleanup(); reject(new Error('프로젝트 열기 완료를 확인하지 못했습니다. 다시 선택해 주세요.')); }, 60000);
const cleanup = (): void => { observer.disconnect(); window.clearTimeout(timer); };
observer.observe(toasts, {childList:true, subtree:true});
});
const transfer = new DataTransfer();
transfer.items.add(new File([text], sample.filename, {type:'application/json'}));
picker.files = transfer.files;
showApp();
picker.dispatchEvent(new Event('change', {bubbles:true}));
await opened;
sampleGrid.querySelectorAll('.primary').forEach(button => button.classList.remove('primary'));
item.classList.add('primary');
status.textContent = `${sample.bridge} · 교각 ${sample.pierCount}기와 지도 포함 지반을 열었습니다.`;
} catch (error) {
showLanding();
selectPanel('landing-sample-panel');
status.textContent = `${sample.bridge}: ${(error as Error).message}`;
} finally {
sampleLoading = false;
sampleGrid.removeAttribute('aria-busy');
sampleGrid.querySelectorAll('button').forEach(button => { button.disabled = false; });
}
});
sampleGrid.appendChild(item);
});
}
if (homeButton) {
homeButton.addEventListener('click', () => {
showLanding();
});
}
if (appRoot) {
appRoot.hidden = true;
appRoot.setAttribute('aria-hidden', 'true');
}
if (homeButton) {
homeButton.hidden = true;
}
showLanding();