Update landing page and ground link features
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
1 parent
a5cc458252
commit
7efe9097ce
28 files changed
+928
-120
No files matched your search
@@ -0,0 +1,2 @@
|
||||
|
||||
.cache/
|
||||
@@ -0,0 +1,80 @@
|
||||
# 샘플 교량 탐색 개선 결과 — 2026-11-04
|
||||
|
||||
## 범위
|
||||
현재 경로에 Test랜딩페이지 폴더가 없어 기존 랜딩페이지에서 개선했다. 화이트 테마와 지도/목록/상세 3단 구조를 유지했다. 폴더 외 변경은 이번에 사용자가 허용한 src/core/rebar/column.ts의 1줄뿐이다. 샘플 JSON 원본과 프로젝트 Store는 변경하지 않았다.
|
||||
|
||||
## 실제 불러오기 오류 원인과 수정
|
||||
수정 전 9개는 기존 파일 로더로 열렸고 대호지교·승천천교·감북교는 메타데이터 blockReason 때문에 로더 호출 전에 차단됐다. 세 파일을 원래 #project-file로 직접 열어 확인했다.
|
||||
|
||||
- 대호지교·감북교: 현장타설말뚝 모델에 확대기초가 없는데 columnInputFor가 model.footings[0].top을 읽어 Cannot read properties of undefined (reading 'top')가 발생했다.
|
||||
- 사용자 허용 후 model.footings[0]?.top ?? col.bottom으로 변경했다. 확대기초가 있으면 기존 값을 유지하며, 없을 때 실제 모델 기둥 하단을 사용한다. 현장타설말뚝 철근 상세 설계까지 검증한 변경은 아니다.
|
||||
- 승천천교: 원본에 교각 데이터가 0기지만 기존 로더는 파일을 정상적으로 열 수 있었다. 임의 교각을 생성하지 않고 지반·지도 자료로 구분한다.
|
||||
- 기존 UI의 지도 레이어가 정확히 2개가 아니면 전체 프로젝트를 거부하던 조건도 제거했다. 교량명/교각수는 원본과 검증하고 지도·지형 누락은 상태로 표시한다.
|
||||
|
||||
## 기존 로더 재사용과 선택 일치
|
||||
sampleProjects의 ID/파일명/URL → sampleResource의 실제 파일 확인 → 기존 #project-file change → 기존 openProjectFile → 기존 openProjectText 경로를 유지했다. 별도 Store나 가상 프로젝트를 만들지 않았다.
|
||||
|
||||
미리보기와 로드는 같은 URL의 리소스를 공유한다. 선택 ID 하나로 지도·목록·미리보기·위치정보·불러오기 대상을 갱신한다. 불러오는 동안 선택을 잠그고 기존 로더의 새 성공/실패 메시지로 완료를 판단한다. 실패는 교량명과 실제 오류로 표시하며 재시도가 가능하다. 이전 미리보기의 비동기 결과가 현재 선택을 덮어쓰지 않는다.
|
||||
|
||||
## 실제 지도·지형·교각 미리보기
|
||||
PNG 썸네일 대신 실시간 Three.js 미리보기로 변경했다. 기존 buildPier, buildAllPiers, terrainGeometry, buildTerrainMapGroup를 재사용하고 저장 mapLayers.image/survey_corners를 같은 pivot과 DTM 격자로 매핑한다.
|
||||
|
||||
- 저장 위성/2D 지도 전환, 드래그 회전, 휠 확대/축소, 큰 화면, 맞춤/전체 보기 제공.
|
||||
- 처음 두 교각 주변을 대표 구간으로 맞추되 전체 교각을 장면에 유지하며 전체 버튼으로 전체 배치를 확인.
|
||||
- 기초·말뚝은 실제 모델의 부재를 사용하고 지반 투명 버튼으로 지하 부재 확인.
|
||||
- 승천천교는 실제 저장 지도·지반과 교각 0기를 명시.
|
||||
- 지형/지도 미연결과 이미지 오류를 표시. 교량 변경 시 렌더러·텍스처·컨트롤 해제.
|
||||
|
||||
## 지도 확대와 클러스터
|
||||
sample-map-model.ts의 SAMPLE_MAP_CONFIG에서 확대/중심/상한을 관리한다. 초기 확대 7.3, 일반지도 상한 19, 위성지도 상한 23이다. 지역별 실제 고배율 영상 제공 여부는 다르며 실패 시 타일 표시 불가 안내를 표시한다. 상한 이상의 저해상도 타일을 영구 확대하지 않는다.
|
||||
|
||||
선택 시 부드럽게 이동하며 휠·더블클릭·버튼·드래그를 지원한다. 애니메이션 도중에는 기존 타일을 이동하고 도착 후 필요한 타일을 요청한다. 현재 화면에서 32px 이내로 겹치는 마커를 연결 성분으로 묶어 개수와 교량 선택 목록을 표시한다. 확대 시 다시 계산하고 완전히 같은 좌표는 최대 확대에서도 선택 목록으로 유지한다. 원본 좌표를 이동하지 않는다.
|
||||
|
||||
서비스 확인 자료:
|
||||
- https://operations.osmfoundation.org/policies/tiles/
|
||||
- https://operations.osmfoundation.org/files/OWG_Summary_2013-07-01.pdf
|
||||
- https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer?f=pjson
|
||||
|
||||
## 위치정보
|
||||
대표점은 각 파일의 첫 교각 IP1/IP3 중점이다. 교각이 없는 승천천교만 저장 지도 영역 중심을 사용하며 이를 명시한다. Excel X=N/Y=E → 프로그램 x=E/y=N → 지반 X=N/Y=E 규칙을 확인했다. 프로그램에 이미 교환되어 저장된 좌표를 다시 교환하지 않는다.
|
||||
|
||||
하단에 교량명·주소 확인 상태·적용 EPSG·조사 X(N)/Y(E)·WGS84 위도/경도·대표점 기준을 표시한다. 위치 버튼은 같은 선택 ID로 상단 지도를 확대한다. sample-locations.ts는 각 파일 EPSG에서 대표점을 WGS84로 변환한 결과다.
|
||||
|
||||
원본 CRS 추정 상태를 유지하고 원본 CRS 미확정을 표시한다. EPSG를 측량 원자료로 확정한 것은 아니다. 확인된 행정주소가 원자료에 없어 주소 확인 불가로 표시한다. 역지오코딩 API를 임의 연결하거나 주소를 추정하지 않았다.
|
||||
|
||||
## 검증 결과
|
||||
- 샘플 12개 모두 패널 버튼으로 실제 파일 열기 성공, 각 프로젝트명과 교각수 일치. 대호지교 12기, 감북교 2기, 승천천교 0기.
|
||||
- 12개 미리보기에서 저장 지도 2종·지반 연결 확인. 교각 수는 원본과 동일.
|
||||
- 클러스터 개별 선택·확대 시 분리·동일 좌표 유지, 목록/버튼/위치정보 동기화 검증.
|
||||
- 휠/더블클릭, 상한 19/23, HTTP 503 오류 표시와 재시도, 불러오기 중 선택 잠금, 실제 3D 드래그 회전 검증.
|
||||
- 1366×768 가로 넘침 없음. 불러오기 하단 약 738px로 화면 안에 표시. 1920×1080 확인. 브라우저 페이지 오류 0건.
|
||||
- 신규 회귀 검사 5개 통과: 클러스터, 실제 대표좌표, 전체 63개 모델의 기둥 배근 높이 및 입력 불변성 포함.
|
||||
- 타입 검사·프로덕션 빌드 통과. 기존 대형 번들 경고 유지.
|
||||
- 전체 543개 중 539개 통과. 기존 axis.test.ts, bearing-coordinate-frame.test.ts, integrated-block.test.ts, validate-and-foundation.test.ts 실패 4건 유지. 커밋하지 않았다.
|
||||
|
||||
승천천교는 원본 교각이 없어 메인 형상 화면에 교각 3D를 복원할 수 없다. 통합 미리보기에서 실제 지도·지반을 확인하며, 교각 복원에는 원본 방위각/교각 자료가 필요하다.
|
||||
|
||||
## 변경 파일
|
||||
sample-loader.ts, sample-resource.ts: 기존 파일 로더 연결·완료/실패 처리·리소스 공유.
|
||||
sample-preview.ts: 지도·지형·교각의 실시간 통합 미리보기.
|
||||
sample-map.ts, sample-map-model.ts: 확대·이동·타일 상한·클러스터 선택.
|
||||
sample-explorer.ts/css, sample-locations.ts: 단일 선택 상태·위치정보·상세 레이아웃.
|
||||
landing-page.ts, landing-guide.ts: 시작 화면 연결·안내 갱신.
|
||||
sample-explorer.test.ts: 실제 데이터와 클러스터 회귀 검사.
|
||||
사용자 허용 외부 변경: src/core/rebar/column.ts 1줄.
|
||||
|
||||
검증 기록과 스크린샷은 이 폴더 .cache/에 보존한다. 원본 JSON·도면·교각 속성은 수정하지 않았다.
|
||||
|
||||
|
||||
## 추가 변경: 기설계 교량 / 간단한 지역명
|
||||
패널 제목·위치/목록 제목·불러오기 이름을 기설계 교량으로 바꿨다. 사용자 명시 허용 후 대표 좌표 12개를 ArcGIS reverseGeocode에 조회해 시·도와 시·군만 저장했다. 상세주소는 저장하지 않는다. 예: 산외교 경상남도 밀양시, 계문철교·기양천교 전북특별자치도 익산시. 원본 CRS 미확정 상태는 유지하며 좌표 기준 지역명이다. 이 추가 변경은 위의 초기 주소 확인 불가 상태를 대체한다.
|
||||
|
||||
교량 선택 시 현재 고배율을 그대로 유지하지 않고, 대표 좌표에서 70km 이내 교량들을 함께 담는 범위를 다시 계산한다. 근접 그룹 선택은 유지한다. 브라우저 검사에서 계문철교 선택 시 인근 기양천교 등 주변 교량 마커 동시 표시와 익산시 지역명을 확인했다. 제목/지역명/주변 마커 동기화 검사와 기존 회귀 검사 5개, 타입 검사가 통과했다.
|
||||
|
||||
지역명 출처: https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer (조회 2026-11-04). 조회 결과의 Region/City 등 시군 단위 필드만 사용했다. 실제 좌표는 기존 대표점이며 새 좌표를 추정하거나 원본 측량 위치를 보정하지 않았다.
|
||||
|
||||
|
||||
## 최신 목록/마커 변경
|
||||
교량 목록은 3열×3행(행 높이 46px, 표시 높이 150px)이며 12개 중 나머지는 목록 안에서 스크롤한다. 목록 클릭 시 선택한 실제 교량 좌표로 14단계 확대한다. 지도 클러스터 및 그룹 선택 팝업은 제거하고 교량마다 개별 번호 마커를 생성한다. 겹치는 번호만 지시선으로 분산해 각 교량을 구분하며 원본 좌표는 변경하지 않는다. 화면 밖 마커는 지도 이동/확대로 확인한다.
|
||||
|
||||
1366×768에서 3열, 표시 높이 150px/스크롤 높이 202px, 개별 마커 12개/클러스터 0개, 클릭 확대 14단계를 확인했다. 불러오기 버튼 하단 약 738px로 화면 안에 유지된다. 클러스터 관련 미사용 코드와 검사는 제거했다. 최신 회귀 검사는 좌표·확대 설정·실제 63개 모델의 3개 검사다.
|
||||
@@ -0,0 +1,31 @@
|
||||
# 랜딩페이지 텍스트·타이포그래피 리터치 — 2026-11-04
|
||||
|
||||
## 변경 문구
|
||||
- 슬로건: 교각·교대 설계의 새로운 기준
|
||||
- 상단 설명: 설계정보 입력부터 3D 모델링, 구조검토, 철근배근, 도면작성 및 수량산출까지 하나의 플랫폼에서
|
||||
- 중앙 보조 문구: 교량 하부구조 3D 통합 모델링
|
||||
- 카드 1: Excel Import / 엑셀로 시작하기 / 설계정보를 불러와 교각·교대 3D 모델을 자동 생성합니다. / Excel 파일 불러오기
|
||||
- 카드 2: Drawing Import / 도면으로 시작하기 / 기존 도면에서 교각·교대 정보를 추출하여 3D 모델을 생성합니다. / 도면 불러오기
|
||||
- 패널: EXPLORE PROJECTS / 교각·교대 설계 라이브러리 / 12 PROJECTS
|
||||
- 지도: 설계 프로젝트 위치 / 일반 / 위성
|
||||
- 목록: 기존 설계 프로젝트 / 총 12개 프로젝트
|
||||
- 미리보기: 하부구조 3D 미리보기
|
||||
- 위치정보: 프로젝트 위치정보
|
||||
- 하단 버튼: 설계 프로젝트 불러오기
|
||||
- PIER & ABUT는 유지하며, 실제 교량명·주소·좌표·형식·교각 수는 변경하지 않았다. 교각 없는 원본의 안내도 유지한다.
|
||||
|
||||
## 스타일
|
||||
신규 landing-typography.css는 font-family, font-size, font-weight, letter-spacing, line-height, color, 텍스트 fill/shadow만 조정한다. 기존 폰트 파일을 추가하지 않았다. 타이틀은 기존 산세리프 기반 네이비/블루, 슬로건은 가는 회청색, 카드/패널 제목은 명확한 굵기, 영문 라벨은 작은 글자와 넓은 자간으로 정리했다. 좌표 숫자는 tabular-nums를 유지한다.
|
||||
|
||||
## 배치·기능 검증
|
||||
landing-page.ts와 sample-explorer.ts는 텍스트 리터럴과 타이포 CSS import만 수정했다. HTML 요소 구조, 이벤트, 선택 상태, 지도·로더·미리보기 로직을 변경하지 않았다. 카드/패널 크기·위치, 지도, 프로젝트 목록, 미리보기, 버튼, 회사 로고, 히어로 이미지와 배경은 유지한다.
|
||||
|
||||
1366×768와 1920×1080에서 수정 전후 카드 그룹/카드/Excel 버튼/우측 패널/패널 헤더/지도/목록/미리보기/불러오기 버튼/로고 영역의 x,y,width,height를 비교했다. 0.05px 허용 범위에서 모두 동일하다. 글자 크기로 발생했던 1px 이동은 기존 줄 높이를 복원해 제거했다. 타이틀 글자의 폭과 가운데 정렬 결과는 글꼴 변경에 따라 달라지며, 주변 UI 배치에는 영향을 주지 않는다.
|
||||
|
||||
두 해상도에서 Excel 입력, 도면 추출 팝업, 프로젝트 선택 후 지도 확대, 실제 운곡교 프로젝트 불러오기를 확인했다. 페이지 오류 0건. 타입 검사·프로덕션 빌드 통과(기존 대형 번들 경고 유지).
|
||||
|
||||
## 시안과 남는 차이
|
||||
시안의 원본 폰트 파일/설정은 제공되지 않아 기존 설치 글꼴로 근사했다. OS별 글꼴 대체·안티앨리어싱에 따라 글자 모양은 완전히 같지 않을 수 있다. 기존 좁은 우측 패널과 축소된 카드 크기를 유지하기 위해 일부 글자는 시안보다 작다. 시안 속 교각수·좌표 등 예시 데이터는 실제 데이터로 바꾸지 않고 기존 프로젝트 값을 유지했다. 그림과 화면 구성 자체는 이번 작업 대상이 아니다.
|
||||
|
||||
변경 파일: landing-page.ts, sample-explorer.ts, 신규 landing-typography.css.
|
||||
검사 결과: .cache/typography-results.json, .cache/typography-1366.png, .cache/typography-1920.png.
|
||||
@@ -67,6 +67,7 @@ export function createDrawingPopup(root: HTMLElement, beforeOpen: () => void): {
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} else {
|
||||
if (!window.loadBytesToApp) throw new Error('교각 Excel 가져오기를 찾을 수 없습니다.');
|
||||
window.dispatchEvent(new CustomEvent('pierzainer:ground-source',{detail:{name:book.fileName,bytes,folder:job.folder}}));
|
||||
window.loadBytesToApp(bytes, book.fileName);
|
||||
const name = document.querySelector<HTMLInputElement>('#project-name')!;
|
||||
const bridge = document.querySelector<HTMLInputElement>('#bridge-name')!;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { sampleProjects } from './sample-projects';
|
||||
type StartMode = 'excel' | 'drawing' | 'sample';
|
||||
const modeNames: Record<StartMode,string> = {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} {
|
||||
@@ -18,7 +18,7 @@ export function createLandingGuide(root: HTMLElement): {close:(restoreFocus?:boo
|
||||
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>
|
||||
<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>
|
||||
@@ -39,6 +39,7 @@ export function createLandingGuide(root: HTMLElement): {close:(restoreFocus?:boo
|
||||
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;};
|
||||
@@ -53,8 +54,8 @@ export function createLandingGuide(root: HTMLElement): {close:(restoreFocus?:boo
|
||||
'종평면도·받침배치도·ELEVATION DWG가 들어 있는 폴더를 선택하고 자동 추출 시작을 누르세요. 진행 내용이 팝업에 표시됩니다.',
|
||||
'결과 목록의 교각 불러오기를 눌러 Excel 인식 결과를 확인하고 형상을 생성합니다. Excel 저장으로 파일만 받을 수도 있습니다.'],
|
||||
sample:['파일 준비 없이 실제 교각과 지반 모델을 살펴보려면 샘플 프로젝트를 선택하세요.',
|
||||
'샘플 선택 패널에서 산외교·운곡교 등 12개 교량의 이름과 교각 수를 확인하고 원하는 교량을 선택합니다.',
|
||||
`현재 ${sampleProjects.filter(s=>!s.blockReason).length}개 교량은 교각과 원도면 지반면, 위성사진, 2D 지도를 함께 엽니다. 확인이 필요한 샘플은 이유를 안내합니다. 설계 지반고는 Excel 원래 값을 유지하며, 기존 검증 알림은 확인이 필요합니다.`]
|
||||
'오른쪽 고정 패널에서 산외교·운곡교 등 12개 교량의 이름과 교각 수를 확인하고 원하는 교량을 선택합니다.',
|
||||
`현재 ${sampleProjects.filter(s=>s.pierCount>0).length}개 교량은 교각과 원도면 지반면, 위성사진, 2D 지도를 함께 엽니다. 교각 데이터가 없는 승천천교는 지반·지도 자료를 표시합니다. 설계 지반고는 Excel 원래 값을 유지하며, 기존 검증 알림은 확인이 필요합니다.`]
|
||||
};
|
||||
const facts: Record<StartMode,string[]>={
|
||||
excel:['선택 가이드 · 교각 설계정보가 담긴 Excel 파일이 있을 때',`현재 주 가져오기 창의 지원 확장자: ${excelFormats.replaceAll(',', ' · ')}`,'확인할 내용 · 시트 인식 결과, 선택 교각, 프로젝트명'],
|
||||
@@ -116,7 +117,7 @@ export function createLandingGuide(root: HTMLElement): {close:(restoreFocus?:boo
|
||||
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();
|
||||
close(false);if(mode==='sample'){root.querySelector<HTMLButtonElement>('.sample-load')?.focus();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']});
|
||||
|
||||
+15
-106
@@ -1,8 +1,10 @@
|
||||
import '../pierzainer_ground/ground-link';
|
||||
import { loadSampleIntoApp } from './sample-loader';
|
||||
import { createSampleExplorer, sampleExplorerMarkup, type Sample } from './sample-explorer';
|
||||
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 './landing-typography.css';
|
||||
import { createDrawingPopup, drawingPopupMarkup } from './drawing-popup';
|
||||
|
||||
const appRoot = document.querySelector<HTMLDivElement>('.app');
|
||||
@@ -33,12 +35,12 @@ const landingPanelMarkup = `
|
||||
<div class="landing-hero-copy">
|
||||
<div class="landing-title-group">
|
||||
<h1 class="landing-title">PIER <span>&</span> ABUT</h1>
|
||||
<h2 class="landing-headline">교각 설계의 새로운 시작</h2>
|
||||
<h2 class="landing-headline">교량 하부구조 3D 통합모델링</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 모델링, 구조해석, 철근배근, 도면 및 수량산출까지 하나의 플랫폼에서
|
||||
설계정보 입력부터 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>
|
||||
@@ -50,7 +52,6 @@ const landingPanelMarkup = `
|
||||
${heroMotionMarkup(promoImageUrl)}
|
||||
</div>
|
||||
</div>
|
||||
<span class="landing-showcase-label">DIGITAL TWIN SHOWCASE <span>브랜딩 모션 · 해석 결과 아님</span></span>
|
||||
</div>
|
||||
|
||||
<section class="landing-cards" aria-label="입력 방식 선택">
|
||||
@@ -58,8 +59,8 @@ const landingPanelMarkup = `
|
||||
<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>
|
||||
<h3>엑셀로 시작하기</h3>
|
||||
<p>설계정보를 불러와 교각·교대 3D 모델을 자동 생성합니다.</p>
|
||||
</div>
|
||||
<div class="landing-card-actions">
|
||||
<span class="landing-card-note">Excel · .xlsx</span>
|
||||
@@ -71,8 +72,8 @@ const landingPanelMarkup = `
|
||||
<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>
|
||||
<h3>도면으로 시작하기</h3>
|
||||
<p>기존 도면에서 교각·교대 정보를 추출하여 3D 모델을 생성합니다.</p>
|
||||
</div>
|
||||
<div class="landing-card-actions">
|
||||
<span class="landing-card-note" id="landing-drawing-status" role="status" aria-live="polite">프로그램 안에서 도면 추출</span>
|
||||
@@ -80,18 +81,7 @@ const landingPanelMarkup = `
|
||||
</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 & ABUT</span></div>
|
||||
@@ -99,6 +89,7 @@ const landingPanelMarkup = `
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
${sampleExplorerMarkup}
|
||||
<section id="landing-excel-panel" class="landing-panel" aria-label="엑셀 입력 패널">
|
||||
<div class="landing-panel-head">
|
||||
<h3>엑셀 입력</h3>
|
||||
@@ -119,18 +110,7 @@ const landingPanelMarkup = `
|
||||
|
||||
${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>
|
||||
`;
|
||||
|
||||
@@ -240,17 +220,13 @@ const bindCard = (cardSelector: string, panelId: string, mode: 'panel' | 'picker
|
||||
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();
|
||||
@@ -286,74 +262,7 @@ document.querySelectorAll<HTMLButtonElement>('[data-pick-file]').forEach((button
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
createSampleExplorer(landingOverlay,(sample:Sample)=>loadSampleIntoApp(sample,showApp,showLanding));
|
||||
|
||||
if (homeButton) {
|
||||
homeButton.addEventListener('click', () => {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/* Text-only retouch. No layout, spacing, dimensions, imagery or interaction rules. */
|
||||
.landing-white .landing-title{font-family:'Arial','Segoe UI',sans-serif;font-size:clamp(46px,5.6vw,98px);line-height:clamp(46px,5.4vw,94px);font-weight:750;letter-spacing:.005em;color:#12243b;-webkit-text-fill-color:#12243b;text-shadow:0 1px 0 #ffffff}
|
||||
.landing-white .landing-title>span{font-weight:400;color:#0788ce;-webkit-text-fill-color:#0788ce}
|
||||
.landing-white .landing-headline{font-weight:300;letter-spacing:.025em;color:#425e79}
|
||||
.landing-white .landing-brand-meta .landing-subtitle{color:#52677f;font-weight:400;letter-spacing:.005em}
|
||||
.landing-white .landing-kicker{font-weight:600;color:#365474}
|
||||
.landing-white .landing-showcase-label{font-size:17px;line-height:21px;letter-spacing:.045em;font-weight:400;color:#466d91}
|
||||
.landing-white .landing-showcase-label>span{font-size:7px;font-weight:400;letter-spacing:.04em;color:#899cad;line-height:21px}
|
||||
.landing-white .landing-card-eyebrow{font-size:9px;font-weight:600;letter-spacing:.12em;color:#0787c8}
|
||||
.landing-white .landing-card h3{font-weight:700;letter-spacing:-.025em;color:#152a42}
|
||||
.landing-white .landing-card p{font-size:11px;font-weight:400;line-height:15.95px;letter-spacing:.005em;color:#536b82}
|
||||
.landing-white .landing-card .landing-primary{color:#007fcb}
|
||||
.landing-white .sample-explorer-head>div>span{font-size:10px;line-height:21px;font-weight:600;letter-spacing:.11em;color:#57728c}
|
||||
.landing-white .sample-explorer-head h2{font-size:22px;line-height:33px;font-weight:700;letter-spacing:-.04em;color:#152a42}
|
||||
.landing-white .sample-explorer-head>b{font-weight:300;color:#008bd3}
|
||||
.landing-white .sample-explorer-head>b>span{font-weight:500;color:#008bd3;letter-spacing:.1em}
|
||||
.landing-white .sample-section-title h3{font-size:13px;line-height:21px;font-weight:700;letter-spacing:-.02em;color:#1c334d}
|
||||
.landing-white .sample-section-title>span{font-weight:400;color:#788ca2;letter-spacing:.01em}
|
||||
.landing-white .sample-row strong{font-weight:700;color:#203852;letter-spacing:-.025em}
|
||||
.landing-white .sample-row small{font-weight:400;color:#74889e;letter-spacing:-.01em}
|
||||
.landing-white .sample-row-number{font-weight:500;font-variant-numeric:tabular-nums}
|
||||
.landing-white .sample-location-heading strong{font-weight:700;color:#233b55;letter-spacing:-.01em}
|
||||
.landing-white .sample-location dt{color:#8495a7;font-weight:400}
|
||||
.landing-white .sample-location dd{color:#425e78;font-weight:500;font-variant-numeric:tabular-nums}
|
||||
.landing-white .sample-address{color:#647e94}
|
||||
.landing-white .sample-load{font-size:12px;line-height:14.3px;font-weight:600;letter-spacing:.015em;color:#fff}
|
||||
@media(max-height:850px){.landing-white .sample-explorer-head h2{font-size:19px;line-height:28.5px}}
|
||||
@media(max-width:1100px){.landing-white .landing-title{font-size:48px;line-height:48px}}
|
||||
@media(max-width:700px){.landing-white .landing-title{font-size:44px;line-height:44px}.landing-white .landing-card p{font-size:11px}.landing-white .landing-showcase-label{font-size:12px}.landing-white .sample-explorer-head h2{font-size:19px;line-height:28.5px}}
|
||||
/* Preserve the original header line boxes while changing glyph styling. */
|
||||
.landing-white .sample-explorer-head>div>span{font-size:9px;line-height:1.5}
|
||||
.landing-white .sample-selection>.sample-section-title h3{line-height:20px}
|
||||
@media(min-width:1101px) and (max-width:1500px){.landing-white .landing-title{font-size:5vw;letter-spacing:0}}
|
||||
@@ -0,0 +1,114 @@
|
||||
.landing-white .landing-shell{display:grid;grid-template-columns:minmax(0,3fr) minmax(320px,1fr)}
|
||||
.landing-white .landing-hero{min-width:0;padding-inline:clamp(20px,2.5vw,48px)}
|
||||
.landing-white .landing-visual{width:100%;max-width:1500px}
|
||||
.landing-white .landing-cards{grid-template-columns:repeat(2,minmax(0,1fr));width:min(820px,95%);gap:20px}
|
||||
.landing-white .landing-brand-meta{width:min(310px,29%)}
|
||||
.landing-white .landing-title-group{padding-top:38px}
|
||||
.landing-white .landing-title{font-size:clamp(46px,5.4vw,94px)}
|
||||
.sample-explorer{height:100dvh;min-height:0;min-width:0;padding:24px 22px 18px;display:grid;grid-template-rows:auto minmax(200px,1.5fr) minmax(130px,1fr) auto;gap:18px;background:linear-gradient(150deg,#fff,#f3f8fd);border-left:1px solid #dce6f0;box-shadow:-12px 0 50px #35659108;position:relative;z-index:5;color:#14263d}
|
||||
.sample-explorer-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
||||
.sample-explorer-head>div>span{font-size:9px;letter-spacing:.17em;color:#61829e;font-weight:600}
|
||||
.sample-explorer-head h2{font-size:22px;margin:7px 0 0;letter-spacing:-.04em}
|
||||
.sample-explorer-head>b{font-size:27px;font-weight:400;color:#087fcb}.sample-explorer-head>b>span{display:block;font-size:8px;letter-spacing:.12em}
|
||||
.sample-section-title{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:25px;margin-bottom:9px}.sample-section-title h3{margin:0;font-size:12px;font-weight:700}.sample-section-title>span{font-size:10px;color:#7690a6}
|
||||
.sample-map-section{min-height:0;display:grid;grid-template-rows:auto minmax(0,1fr) auto}
|
||||
.sample-map{position:relative;overflow:hidden;border-radius:14px;border:1px solid #dbe7f0;background:#e8f1f8;touch-action:none;cursor:grab;isolation:isolate}
|
||||
.sample-map-tiles{position:absolute;inset:0;z-index:0}.sample-map-tiles img{position:absolute;max-width:none;user-select:none}
|
||||
.sample-map-markers{position:absolute;inset:0;z-index:1;pointer-events:none}
|
||||
.sample-marker{position:absolute;transform:translate(-50%,-50%);border:2px solid white;border-radius:50%;width:26px;height:26px;padding:0;background:#087fcb;color:white;font-size:10px;font-weight:700;box-shadow:0 2px 8px #173f6540;pointer-events:auto;cursor:pointer;transition:box-shadow .2s,background .2s}
|
||||
.sample-marker.is-selected{background:#112c49;box-shadow:0 0 0 4px #38bdf84a;z-index:3}.sample-marker.is-hovered{background:#04a5e1;box-shadow:0 0 0 5px #38bdf848;z-index:4}
|
||||
.sample-map-zoom{position:absolute;right:9px;top:9px;z-index:3;display:grid;border-radius:7px;overflow:hidden;box-shadow:0 2px 9px #183d6420}.sample-map-zoom button{border:0;border-bottom:1px solid #e5edf3;background:white;color:#31536e;width:27px;height:27px;font-size:17px;cursor:pointer}
|
||||
.sample-map-attribution{position:absolute;bottom:0;right:0;background:#ffffffe8;font-size:8px;color:#456078;padding:3px 5px;z-index:4}.sample-map-attribution a{color:inherit}.sample-map-note{font-size:9px;color:#8296a8;margin:6px 0 0}.sample-map-error{position:absolute;bottom:25px;left:10px;background:#ffffffe8;border-radius:4px;padding:5px;font-size:9px;color:#64748b;z-index:4}.sample-map-error[hidden]{display:none}
|
||||
.sample-map-modes{display:flex;border:1px solid #dce6f0;border-radius:7px;padding:2px;background:#edf4fa}.sample-map-modes button{font-size:10px;border:0;background:transparent;color:#698095;border-radius:4px;padding:4px 9px;cursor:pointer}.sample-map-modes button[aria-pressed=true]{background:white;color:#087fcb;box-shadow:0 1px 4px #42678a14}
|
||||
.sample-list-section{min-height:0;display:grid;grid-template-rows:auto minmax(0,1fr)}.sample-list{overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:#c6d8e6 transparent;padding:2px 3px 2px 0;display:flex;flex-direction:column;gap:5px}
|
||||
.sample-row{display:flex;align-items:center;gap:12px;text-align:left;width:100%;border:1px solid transparent;background:transparent;padding:10px 11px;border-radius:10px;color:#14263d;cursor:pointer;flex-shrink:0;transition:background .2s,border-color .2s}.sample-row:hover,.sample-row.is-hovered{background:#edf6ff;border-color:#d5e9fa}.sample-row.is-selected{background:#eaf4ff;border-color:#acd5f2}.sample-row-number{font-size:10px;color:#7694ac;display:grid;place-items:center;border:1px solid #dce6f0;border-radius:7px;width:28px;height:30px;background:white}.sample-row.is-selected .sample-row-number{background:#087fcb;color:white;border-color:#087fcb}.sample-row strong{display:block;font-size:12px}.sample-row small{display:block;font-size:10px;color:#7d91a3;margin-top:4px}.sample-row-arrow{margin-left:auto;color:#8aacc5;font-size:15px}
|
||||
.sample-selection{border-top:1px solid #dce6f0;padding-top:13px;min-width:0}.sample-selection-body{display:grid;grid-template-columns:46% minmax(0,1fr);gap:12px;align-items:center}.sample-preview{border:1px solid #dce7ef;border-radius:10px;background:#eff5fa;aspect-ratio:2;display:grid;place-items:center;overflow:hidden;color:#718ca0;font-size:10px}.sample-preview img{display:block;width:100%;height:100%;object-fit:contain}.sample-selection-info h3{font-size:17px;letter-spacing:-.03em;margin:0 0 7px}.sample-selection-info p{font-size:10px;color:#5c7589;line-height:1.6;margin:0}.sample-selection-info>span{font-size:9px;color:#8199aa;display:block;margin-top:4px}
|
||||
.sample-load{width:100%;margin-top:12px;padding:12px;border:0;border-radius:9px;background:#087fcb;color:white;text-align:left;display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:600;cursor:pointer;box-shadow:0 4px 14px #087fcb1b}.sample-load:hover{background:#066fad}.sample-load:disabled{opacity:.6;cursor:progress}
|
||||
.sample-explorer #landing-sample-status{font-size:10px;line-height:1.5;color:#747f8a;max-height:44px;overflow:auto;margin:7px 0 0}.sample-explorer #landing-sample-status:empty{display:none}.sample-explorer button:focus-visible{outline:2px solid #087fcb;outline-offset:2px}
|
||||
.sample-explorer.is-guided{box-shadow:inset 0 0 0 3px #38bdf8}
|
||||
@media(max-height:850px){.sample-explorer{padding:17px 16px 14px;gap:12px;grid-template-rows:auto minmax(180px,1.4fr) minmax(110px,1fr) auto}.sample-explorer-head h2{font-size:19px}.sample-row{padding:8px 9px}.sample-selection-body{grid-template-columns:42% 1fr}.landing-white .landing-cards{translate:0 -3dvh}.landing-white .landing-card{padding:18px}.landing-white .landing-card p{font-size:11px}.landing-white .landing-title-group{padding-top:28px}}
|
||||
@media(max-width:1100px){.landing-white .landing-shell{grid-template-columns:minmax(0,1fr) 320px}.landing-white .landing-brand-meta{position:static;width:100%;text-align:center;max-width:none;margin-top:12px}.landing-white .landing-hero-copy{display:block}.landing-white .landing-brand-meta .landing-kicker{justify-content:center}.landing-white .landing-brand-meta .landing-subtitle{display:none}.landing-white .landing-title-group{padding-top:0}.landing-white .landing-title{font-size:48px}.landing-white .landing-cards{gap:10px;width:100%}.landing-white .landing-card{padding:14px}.landing-white .landing-card h3{font-size:18px}.landing-white .landing-card-number,.landing-white .landing-card-note{display:none}.landing-white .landing-primary{font-size:11px}}
|
||||
@media(max-width:700px){.landing-white .landing-shell{display:flex;flex-direction:column;overflow:auto}.landing-white .landing-hero{height:660px;flex-shrink:0}.sample-explorer{height:820px;min-height:820px;border-left:0;border-top:1px solid #dce6f0}.landing-white .landing-cards{grid-template-columns:repeat(2,minmax(0,1fr))}.landing-white .landing-title{font-size:44px}.landing-white .landing-company-logos{display:block}}
|
||||
|
||||
.sample-explorer button{min-height:0;letter-spacing:normal;line-height:1.3}.sample-marker{width:26px!important;height:26px!important;min-width:0!important;min-height:0!important;line-height:22px!important;border-radius:50%!important;box-sizing:border-box;padding:0!important;display:block!important}.sample-map-modes button{min-height:23px}.sample-map-zoom button{min-height:27px}.sample-selection-info>span::after{content:" · 대표 교각"}
|
||||
|
||||
@media(max-width:700px){
|
||||
.landing-white .landing-cards{grid-template-columns:1fr;gap:9px;translate:0 0;align-self:end}
|
||||
.landing-white .landing-card{height:84px;min-height:84px;display:grid;grid-template-columns:minmax(0,1fr) auto;padding:10px 12px;animation:none;opacity:1}
|
||||
.landing-white .landing-card>div:first-child{padding-left:44px;min-width:0}
|
||||
.landing-white .landing-card h3{font-size:15px;white-space:normal}
|
||||
.landing-white .landing-card-actions{display:block;margin:0}
|
||||
.landing-white .landing-card-top{position:absolute;left:12px;top:50%;transform:translateY(-50%)}
|
||||
.landing-white .landing-card .landing-primary{font-size:10px;padding:9px;white-space:nowrap;min-height:42px}
|
||||
.landing-white .landing-hero{grid-template-rows:auto minmax(0,1fr) auto auto;padding-bottom:14px}
|
||||
.landing-white .landing-brand-meta{margin-top:8px}.landing-white .landing-title-group{padding-top:0}
|
||||
}
|
||||
/* Integrated 3D / coordinate detail, preserving the three main panel sections. */
|
||||
.sample-explorer{grid-template-rows:auto minmax(200px,1.4fr) minmax(100px,.8fr) 370px}
|
||||
.sample-selection{display:flex;flex-direction:column;gap:5px;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;min-height:0;padding-top:9px}
|
||||
.sample-selection>.sample-section-title{flex-shrink:0;margin:0;min-height:20px}
|
||||
.sample-selection .sample-preview{height:126px;min-height:126px;aspect-ratio:auto;position:relative;display:block;flex-shrink:0;touch-action:none;cursor:grab}
|
||||
.sample-preview canvas{width:100%;height:100%;display:block}
|
||||
.sample-preview-note{position:absolute;bottom:3px;left:5px;right:5px;font-size:8px;color:#294c64;line-height:1.4;background:#ffffffd9;padding:2px 4px;border-radius:3px;pointer-events:none}
|
||||
.sample-preview-tools{position:absolute;right:4px;top:4px;display:flex;gap:3px}.sample-preview-tools button{font-size:8px;padding:4px 5px;border:1px solid #ccdfed;border-radius:4px;background:#fffffff0;color:#315774;min-height:20px;cursor:pointer}.sample-preview-tools button:disabled{opacity:.45}
|
||||
.sample-location{flex-shrink:0;padding:7px 8px;background:#f0f6fb;border:1px solid #dbe8f2;border-radius:8px;font-size:9px;color:#45637a;line-height:1.4}
|
||||
.sample-location-heading{display:flex;align-items:center;justify-content:space-between;gap:3px}.sample-location-heading strong{font-size:10px;color:#234a68}.sample-locate{background:none;border:0;color:#087fcb;font-size:9px;padding:1px 0;cursor:pointer}
|
||||
.sample-address{margin:3px 0;color:#698297}.sample-location dl{display:grid;grid-template-columns:46px minmax(0,1fr);gap:2px 3px;margin:0}.sample-location dt{color:#8095a6}.sample-location dd{margin:0;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.sample-coordinate-basis{display:block;margin-top:3px;font-size:8px;color:#8496a4}
|
||||
.sample-selection .sample-selection-info{display:flex;gap:8px;align-items:center;justify-content:space-between;flex-shrink:0}.sample-selection .sample-selection-info h3{margin:0;font-size:13px}.sample-selection .sample-selection-info p{margin:0;font-size:9px}
|
||||
.sample-selection .sample-load{position:sticky;bottom:0;margin:0;padding:10px;min-height:36px;flex-shrink:0;z-index:2;font-size:11px}.sample-explorer #landing-sample-status{margin:0;max-height:32px;flex-shrink:0}
|
||||
.sample-preview.is-expanded{position:fixed!important;inset:8vh 8vw!important;width:84vw!important;height:84vh!important;min-height:0!important;z-index:10050;box-shadow:0 0 0 100vmax #18314966;border:1px solid #c0d8eb;border-radius:15px}
|
||||
.sample-preview.is-expanded .sample-preview-tools button{font-size:12px;padding:8px 12px}.sample-preview.is-expanded .sample-preview-note{font-size:11px}
|
||||
@media(max-height:850px){.sample-explorer{grid-template-rows:auto minmax(150px,1.3fr) minmax(85px,.8fr) 345px;gap:10px}.sample-selection .sample-preview{height:100px;min-height:100px}.sample-section-title{margin-bottom:5px}.sample-map-note{font-size:8px}.sample-row{padding:6px 8px}.sample-selection{gap:4px}.sample-selection .sample-load{min-height:34px;padding:8px}}
|
||||
@media(max-width:700px){.sample-explorer{height:1000px;min-height:1000px;grid-template-rows:auto minmax(220px,1.4fr) minmax(130px,.8fr) 370px}.sample-selection .sample-preview{height:126px;min-height:126px}}
|
||||
|
||||
@media(min-width:701px) and (max-height:850px){.sample-explorer{grid-template-rows:auto minmax(215px,1.3fr) minmax(95px,.8fr) 320px}}
|
||||
/* Three-column catalogue: three visible rows, remaining bridges scroll inside. */
|
||||
.sample-list{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));grid-auto-rows:46px;gap:6px;align-content:start;max-height:150px;padding:0 2px 0 0}
|
||||
.sample-row{display:grid;grid-template-columns:19px minmax(0,1fr);align-items:center;gap:5px;padding:5px;min-height:46px;border:1px solid #e0eaf2;background:#ffffffa6;border-radius:8px}
|
||||
.sample-row-number{width:19px;height:23px;font-size:8px;border-radius:5px}.sample-row strong{font-size:10px;white-space:nowrap}.sample-row small{font-size:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:3px}.sample-row-arrow{display:none}
|
||||
.sample-explorer{grid-template-rows:auto minmax(200px,1fr) 180px 370px}
|
||||
@media(min-width:701px) and (max-height:850px){.sample-explorer{grid-template-rows:auto minmax(145px,1fr) 180px 320px}.sample-row{padding:5px}.sample-section-title{margin-bottom:5px}}
|
||||
@media(max-width:700px){.sample-explorer{grid-template-rows:auto minmax(220px,1fr) 180px 370px}}
|
||||
/* Compact import cards: preserve clear actions while revealing the showcase caption. */
|
||||
@media(min-width:701px){
|
||||
.landing-white .landing-card{height:clamp(156px,15.6dvh,168px);padding:15px 20px;justify-content:space-between}
|
||||
.landing-white .landing-card>div:first-child{display:grid;grid-template-columns:36px minmax(0,1fr);column-gap:12px;align-content:start}
|
||||
.landing-white .landing-card-top{display:contents;margin:0}
|
||||
.landing-white .landing-card-icon{grid-column:1;grid-row:1 / span 2;width:36px;height:36px}
|
||||
.landing-white .landing-card-number{display:none}
|
||||
.landing-white .landing-card-eyebrow{grid-column:2;grid-row:1;font-size:9px;line-height:12px;align-self:start}
|
||||
.landing-white .landing-card h3{grid-column:2;grid-row:2;margin:2px 0 0;font-size:19px;line-height:23px}
|
||||
.landing-white .landing-card p{grid-column:1 / -1;margin-top:8px;max-width:none;font-size:11px;line-height:1.45}
|
||||
.landing-white .landing-card-actions{margin-top:8px;gap:8px;min-height:32px}
|
||||
.landing-white .landing-card .landing-primary{min-height:32px;padding:7px 11px;font-size:11px}
|
||||
.landing-white .landing-card-note{font-size:9px}
|
||||
}
|
||||
|
||||
/* Raise the two import cards slightly; preserve their dimensions and actions. */
|
||||
@media(min-width:701px){.landing-white .landing-cards{translate:0 calc(-6dvh - 18px)}}
|
||||
@media(min-width:701px) and (max-height:850px){.landing-white .landing-cards{translate:0 calc(-3dvh - 18px)}}
|
||||
|
||||
/* Preview and catalogue exchange positions; each keeps its own row height. */
|
||||
.sample-explorer{grid-template-rows:auto minmax(200px,1fr) 370px 180px}
|
||||
.sample-selection{border-top:0;padding-top:0}
|
||||
.sample-list-section{border-top:1px solid #dce6f0;padding-top:10px}
|
||||
@media(min-width:701px) and (max-height:850px){.sample-explorer{grid-template-rows:auto minmax(145px,1fr) 320px 180px}}
|
||||
@media(max-width:700px){.sample-explorer{grid-template-rows:auto minmax(220px,1fr) 370px 180px}}
|
||||
|
||||
/* Keep the selected project and load action below the catalogue. */
|
||||
.sample-explorer{grid-template-rows:auto minmax(200px,1fr) 290px 260px}
|
||||
.sample-list-section{grid-template-rows:auto minmax(0,1fr) auto;gap:7px}
|
||||
.sample-project-action{min-width:0;padding-top:5px}
|
||||
.sample-project-action .sample-selection-info{display:flex;gap:8px;align-items:center;justify-content:space-between}
|
||||
.sample-project-action .sample-selection-info h3{margin:0;font-size:13px}
|
||||
.sample-project-action .sample-selection-info p{margin:0;font-size:9px}
|
||||
.sample-project-action .sample-load{margin-top:7px;min-height:36px;padding:10px}
|
||||
@media(min-width:701px) and (max-height:850px){.sample-explorer{grid-template-rows:auto minmax(145px,1fr) 250px 250px}}
|
||||
@media(max-width:700px){.sample-explorer{grid-template-rows:auto minmax(220px,1fr) 290px 260px}}
|
||||
|
||||
/* Let preview content set its height instead of reserving an empty strip. */
|
||||
.sample-explorer{grid-template-rows:auto minmax(200px,1fr) auto 260px;gap:12px}
|
||||
.sample-map-section{grid-template-rows:auto minmax(0,1fr)}
|
||||
.sample-selection{overflow:visible;padding:0;gap:5px}
|
||||
@media(min-width:701px) and (max-height:850px){.sample-explorer{grid-template-rows:auto minmax(145px,1fr) auto 250px;gap:10px}}
|
||||
@media(max-width:700px){.sample-explorer{grid-template-rows:auto minmax(220px,1fr) auto 260px}}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe,it,expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { mercator,SAMPLE_MAP_CONFIG } from './sample-map-model';
|
||||
import { sampleLocations } from './sample-locations';
|
||||
import { buildPier } from '../src/core/geometry';
|
||||
import { columnInputFor } from '../src/core/rebar/column';
|
||||
import type { PierInput } from '../src/core/types';
|
||||
describe('샘플 탐색 실제 데이터 회귀 검사',()=>{
|
||||
it('확대 설정은 기존 최대 15보다 높으며 원점 투영은 유한하다',()=>{
|
||||
expect(SAMPLE_MAP_CONFIG.initialZoom).toBeGreaterThan(6);expect(SAMPLE_MAP_CONFIG.streetMaxZoom).toBe(19);expect(SAMPLE_MAP_CONFIG.satelliteMaxZoom).toBe(23);
|
||||
expect(mercator(0,0)).toEqual([128,128]);expect(mercator(127,90).every(Number.isFinite)).toBe(true);
|
||||
});
|
||||
it('대표 좌표는 실제 첫 교각 IP 중점이며 프로그램 축을 중복 교환하지 않는다',()=>{
|
||||
for(const loc of sampleLocations){const raw=JSON.parse(readFileSync(resolve(import.meta.dirname,`assets/samples/sample${String(loc.sample).padStart(2,'0')}.pzr.json`),'utf8'));
|
||||
expect(loc.crs).toBe(raw.project.crs);expect(loc.crsAssumed).toBe(raw.sampleProvenance.crsAssumed);
|
||||
if(raw.piers.length){const ips=raw.piers[0].input.ips;expect(loc.surveyY).toBeCloseTo((ips[0].x+ips[2].x)/2,6);expect(loc.surveyX).toBeCloseTo((ips[0].y+ips[2].y)/2,6);}
|
||||
expect(loc.lat).toBeGreaterThan(32);expect(loc.lat).toBeLessThan(39);expect(loc.lon).toBeGreaterThan(124);expect(loc.lon).toBeLessThan(132);
|
||||
}
|
||||
});
|
||||
it('전체 63개 실제 모델에서 기둥 배근 높이를 얻고 확대기초 없는 샘플도 예외를 내지 않는다',()=>{
|
||||
let count=0,withoutFooting=0;
|
||||
for(let i=1;i<=12;i++){const raw=JSON.parse(readFileSync(resolve(import.meta.dirname,`assets/samples/sample${String(i).padStart(2,'0')}.pzr.json`),'utf8'));
|
||||
for(const pier of raw.piers){const input=pier.input as PierInput,before=JSON.stringify(input),model=buildPier(input),result=columnInputFor(model);
|
||||
expect(Number.isFinite(result.Hc)).toBe(true);expect(result.Hc).toBeCloseTo((model.levelBottom-(model.footings[0]?.top??model.columns[0].bottom))*1000,1);expect(JSON.stringify(input)).toBe(before);count++;if(!model.footings.length)withoutFooting++;
|
||||
}
|
||||
}expect(count).toBe(63);expect(withoutFooting).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { sampleProjects } from './sample-projects';
|
||||
import { sampleLocations } from './sample-locations';
|
||||
import { renderSamplePreview } from './sample-preview';
|
||||
import { createSampleMap } from './sample-map';
|
||||
import './sample-explorer.css';
|
||||
export type Sample=typeof sampleProjects[number];
|
||||
export const sampleExplorerMarkup=`<aside class="sample-explorer" aria-label="기설계 교량" data-guide-target="sample">
|
||||
<header class="sample-explorer-head"><div><span>EXPLORE PROJECTS</span><h2>교각·교대 설계 라이브러리</h2></div><b>12<span> PROJECTS</span></b></header>
|
||||
<section class="sample-map-section"><div class="sample-section-title"><h3>설계 프로젝트 위치</h3><div class="sample-map-modes" role="group" aria-label="지도 종류"><button type="button" data-map-mode="street" aria-pressed="true">일반</button><button type="button" data-map-mode="satellite" aria-pressed="false">위성</button></div></div><div class="sample-map" aria-label="대한민국 기설계 교량 지도"><div class="sample-map-tiles"></div><div class="sample-map-markers"></div><div class="sample-map-zoom"><button type="button" data-zoom="1" aria-label="지도 확대">+</button><button type="button" data-zoom="-1" aria-label="지도 축소">−</button><button type="button" data-zoom="0" aria-label="남한 중심 기본 지도">⌂</button></div><span class="sample-map-attribution"></span><span class="sample-map-error" hidden>배경지도 연결 대기 · 좌표 마커 표시</span></div></section>
|
||||
<section class="sample-selection" aria-label="선택 교량 상세"><div class="sample-section-title"><h3>하부구조 3D 미리보기</h3><span class="sample-selection-index"></span></div>
|
||||
<div class="sample-preview" role="region" aria-label="지도·지반·교각 통합 3D 미리보기"></div>
|
||||
<div class="sample-location"><div class="sample-location-heading"><strong>📍 <span class="sample-location-name"></span> 위치정보</strong><button type="button" class="sample-locate" title="선택 교량 위치 확대">지도에서 보기 ↗</button></div><p class="sample-address"></p><dl><dt>좌표계</dt><dd class="sample-crs"></dd><dt>조사좌표</dt><dd class="sample-survey"></dd><dt>위도·경도</dt><dd class="sample-wgs84"></dd></dl><small class="sample-coordinate-basis"></small></div>
|
||||
</section>
|
||||
<section class="sample-list-section"><div class="sample-section-title"><h3>기존 설계 프로젝트</h3><span>총 12개 프로젝트</span></div><div class="sample-list" role="group" aria-label="교량 선택"></div><div class="sample-project-action"><div class="sample-selection-info"><h3></h3><p></p></div><p id="landing-sample-status" role="status" aria-live="polite"></p><button type="button" class="sample-load">설계 프로젝트 불러오기 ↗</button></div></section>
|
||||
</aside>`;
|
||||
export function createSampleExplorer(root:HTMLElement,load:(sample:Sample)=>Promise<void>):void{
|
||||
const panel=root.querySelector<HTMLElement>('.sample-explorer')!,list=panel.querySelector<HTMLElement>('.sample-list')!,status=panel.querySelector<HTMLElement>('#landing-sample-status')!,button=panel.querySelector<HTMLButtonElement>('.sample-load')!;
|
||||
let selected:Sample=sampleProjects[0],busy=false,previewToken=0;
|
||||
let map:ReturnType<typeof createSampleMap>|undefined;
|
||||
const select=(sample:Sample,focus=true)=>{
|
||||
if(busy)return;selected=sample;panel.dataset.selected=String(sample.sample);button.dataset.sample=String(sample.sample);status.textContent=sample.pierCount?'':'원본 프로젝트에 교각 형상이 없습니다. 실제 지반·지도 자료를 엽니다.';
|
||||
button.disabled=false;button.textContent=sample.pierCount?'설계 프로젝트 불러오기 ↗':'설계 프로젝트 불러오기 ↗';
|
||||
panel.querySelectorAll<HTMLElement>('[data-sample-row]').forEach(row=>{const active=Number(row.dataset.sampleRow)===sample.sample;row.classList.toggle('is-selected',active);row.setAttribute('aria-pressed',String(active));});
|
||||
panel.querySelector('.sample-selection-info h3')!.textContent=sample.bridge;
|
||||
panel.querySelector('.sample-selection-info p')!.textContent=`${sampleLocations.find(p=>p.sample===sample.sample)?.types??'형식 미확인'} · 원본 교각 ${sample.pierCount}기`;
|
||||
panel.querySelector('.sample-selection-index')!.textContent=`PROJECT ${String(sample.sample).padStart(2,'0')}`;
|
||||
const loc=sampleLocations.find(p=>p.sample===sample.sample);
|
||||
panel.querySelector('.sample-location-name')!.textContent='프로젝트';const address=panel.querySelector<HTMLElement>('.sample-address')!;address.textContent=loc?.address??'주소 확인 불가';address.title='ArcGIS 역지오코딩 · 지도 좌표 기준 시·도 / 시·군 · 상세주소 제외';
|
||||
panel.querySelector('.sample-crs')!.textContent=loc?`${loc.crs}${loc.crsAssumed?' · 원본 CRS 미확정':''}`:'좌표계 미확인';
|
||||
panel.querySelector('.sample-survey')!.textContent=loc?`X(N) ${loc.surveyX.toFixed(3)} / Y(E) ${loc.surveyY.toFixed(3)} m`:'좌표 확인 불가';
|
||||
panel.querySelector('.sample-wgs84')!.textContent=loc?`${loc.lat.toFixed(6)}° N / ${loc.lon.toFixed(6)}° E`:'변환 불가';
|
||||
panel.querySelector('.sample-coordinate-basis')!.textContent=loc?`${loc.basis} · 프로그램 x=E/y=N`:'대표 좌표 없음';
|
||||
const token=++previewToken;void renderSamplePreview(panel.querySelector('.sample-preview')!,sample,()=>token===previewToken);
|
||||
map?.highlight();if(focus)map?.focus(sample.sample);
|
||||
};
|
||||
for(const sample of sampleProjects){const row=document.createElement('button');row.type='button';row.dataset.sampleRow=String(sample.sample);row.className='sample-row';
|
||||
row.innerHTML=`<span class="sample-row-number">${String(sample.sample).padStart(2,'0')}</span><span><strong>${sample.bridge}</strong><small>${sampleLocations.find(p=>p.sample===sample.sample)?.types??'미확인'} · 교각 ${sample.pierCount}기</small></span><span class="sample-row-arrow">↗</span>`;
|
||||
row.addEventListener('click',()=>select(sample));for(const event of ['pointerenter','focus'])row.addEventListener(event,()=>{row.classList.add('is-hovered');map?.hover(sample.sample);});for(const event of ['pointerleave','blur'])row.addEventListener(event,()=>{row.classList.remove('is-hovered');map?.hover(null);});list.append(row);
|
||||
}
|
||||
map=createSampleMap(panel,id=>{const sample=sampleProjects.find(s=>s.sample===id);if(sample){select(sample);list.querySelector(`[data-sample-row="${id}"]`)?.scrollIntoView({block:'nearest'});}},()=>selected.sample,()=>busy);
|
||||
panel.querySelector('.sample-locate')!.addEventListener('click',()=>map!.focus(selected.sample,true));
|
||||
button.addEventListener('click',async()=>{
|
||||
if(busy)return;const target=selected;busy=true;button.disabled=true;button.textContent=`${target.bridge} 불러오는 중…`;list.querySelectorAll('button').forEach(b=>b.disabled=true);map!.refresh();status.textContent=`${target.bridge}: 실제 프로젝트 파일을 여는 중…`;
|
||||
try{await load(target);status.textContent=`${target.bridge}: 교각 ${target.pierCount}기 · 실제 지반/지도 복원 완료`;}
|
||||
catch(error){status.textContent=`${target.bridge}: ${(error as Error).message}`;panel.dataset.loadError=String(target.sample);}
|
||||
finally{busy=false;button.disabled=false;button.textContent=target.pierCount?'설계 프로젝트 불러오기 ↗':'설계 프로젝트 불러오기 ↗';list.querySelectorAll('button').forEach(b=>b.disabled=false);map!.refresh();}
|
||||
});select(selected,false);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { sampleResource } from './sample-resource';
|
||||
import type { Sample } from './sample-explorer';
|
||||
/** Reuse #project-file → openProjectFile → openProjectText; never mutate app state here. */
|
||||
export async function loadSampleIntoApp(sample:Sample,showApp:()=>void,showLanding:()=>void):Promise<void>{
|
||||
const {text}=await sampleResource(sample),picker=document.querySelector<HTMLInputElement>('#project-file'),toasts=document.querySelector('#toast-host');
|
||||
if(!picker||!toasts)throw new Error('기존 프로젝트 파일 로더를 찾을 수 없습니다.');
|
||||
let cleanup=()=>{};
|
||||
const completed=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.replace(/닫기$/,'')));}
|
||||
}
|
||||
});
|
||||
const timer=window.setTimeout(()=>{cleanup();reject(new Error(`${sample.bridge}: 기존 로더의 완료 응답이 없습니다.`));},30000);
|
||||
cleanup=()=>{observer.disconnect();clearTimeout(timer);};observer.observe(toasts,{childList:true,subtree:true});
|
||||
});
|
||||
try{
|
||||
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 completed;
|
||||
}catch(error){showLanding();throw error;}finally{cleanup();}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Representative: first pier IP1/IP3 midpoint; model x=E/y=N. Regional addresses only; CRS provenance retained.
|
||||
export const sampleLocations=[
|
||||
{
|
||||
"sample": 1,
|
||||
"types": "T형 / π형",
|
||||
"lon": 128.79044311673474,
|
||||
"lat": 35.51700431421019,
|
||||
"crs": "EPSG:5187",
|
||||
"surveyX": 324474.2858,
|
||||
"surveyY": 180991.02545000002,
|
||||
"modelX": 180991.02545000002,
|
||||
"modelY": 324474.2858,
|
||||
"basis": "P1 IP1·IP3 중점",
|
||||
"crsAssumed": true,
|
||||
"address": "경상남도 밀양시",
|
||||
"coordinateConvention": "Excel X=N/Y=E; model x=E/y=N; ground X=N/Y=E. One swap on import.",
|
||||
"addressSource": "ArcGIS reverseGeocode / 좌표 기준 시·도·시군 / 2026-11-04"
|
||||
},
|
||||
{
|
||||
"sample": 2,
|
||||
"types": "T형",
|
||||
"lon": 126.77165358999318,
|
||||
"lat": 34.934622206100954,
|
||||
"crs": "EPSG:5186",
|
||||
"surveyX": 259865.6772855,
|
||||
"surveyY": 179138.0886505,
|
||||
"modelX": 179138.0886505,
|
||||
"modelY": 259865.6772855,
|
||||
"basis": "P1 IP1·IP3 중점",
|
||||
"crsAssumed": true,
|
||||
"address": "전남광주통합특별시 나주시",
|
||||
"coordinateConvention": "Excel X=N/Y=E; model x=E/y=N; ground X=N/Y=E. One swap on import.",
|
||||
"addressSource": "ArcGIS reverseGeocode / 좌표 기준 시·도·시군 / 2026-11-04"
|
||||
},
|
||||
{
|
||||
"sample": 3,
|
||||
"types": "π형",
|
||||
"lon": 126.4818578136663,
|
||||
"lat": 36.92409742225621,
|
||||
"crs": "EPSG:5186",
|
||||
"surveyX": 480714.9299995,
|
||||
"surveyY": 153833.31774949998,
|
||||
"modelX": 153833.31774949998,
|
||||
"modelY": 480714.9299995,
|
||||
"basis": "P1 IP1·IP3 중점",
|
||||
"crsAssumed": true,
|
||||
"address": "충청남도 서산시",
|
||||
"coordinateConvention": "Excel X=N/Y=E; model x=E/y=N; ground X=N/Y=E. One swap on import.",
|
||||
"addressSource": "ArcGIS reverseGeocode / 좌표 기준 시·도·시군 / 2026-11-04"
|
||||
},
|
||||
{
|
||||
"sample": 4,
|
||||
"types": "π형",
|
||||
"lon": 127.20474501539715,
|
||||
"lat": 37.3787820194567,
|
||||
"crs": "EPSG:5186",
|
||||
"surveyX": 531070.307163,
|
||||
"surveyY": 218133.9054315,
|
||||
"modelX": 218133.9054315,
|
||||
"modelY": 531070.307163,
|
||||
"basis": "P1 IP1·IP3 중점",
|
||||
"crsAssumed": true,
|
||||
"address": "경기도 광주시",
|
||||
"coordinateConvention": "Excel X=N/Y=E; model x=E/y=N; ground X=N/Y=E. One swap on import.",
|
||||
"addressSource": "ArcGIS reverseGeocode / 좌표 기준 시·도·시군 / 2026-11-04"
|
||||
},
|
||||
{
|
||||
"sample": 5,
|
||||
"types": "형식 미확인",
|
||||
"lon": 127.27228760719316,
|
||||
"lat": 36.7284263792513,
|
||||
"crs": "EPSG:5186",
|
||||
"surveyX": 458909.60870137205,
|
||||
"surveyY": 224322.72489219968,
|
||||
"modelX": 224322.72489219968,
|
||||
"modelY": 458909.60870137205,
|
||||
"basis": "지도 영역 중심 · 교각 좌표 없음",
|
||||
"crsAssumed": true,
|
||||
"address": "충청남도 천안시",
|
||||
"coordinateConvention": "Excel X=N/Y=E; model x=E/y=N; ground X=N/Y=E. One swap on import.",
|
||||
"addressSource": "ArcGIS reverseGeocode / 좌표 기준 시·도·시군 / 2026-11-04"
|
||||
},
|
||||
{
|
||||
"sample": 6,
|
||||
"types": "π형",
|
||||
"lon": 126.9435502204014,
|
||||
"lat": 35.989762767372426,
|
||||
"crs": "EPSG:5186",
|
||||
"surveyX": 376910.212695,
|
||||
"surveyY": 194909.6217045,
|
||||
"modelX": 194909.6217045,
|
||||
"modelY": 376910.212695,
|
||||
"basis": "P1 IP1·IP3 중점",
|
||||
"crsAssumed": true,
|
||||
"address": "전북특별자치도 익산시",
|
||||
"coordinateConvention": "Excel X=N/Y=E; model x=E/y=N; ground X=N/Y=E. One swap on import.",
|
||||
"addressSource": "ArcGIS reverseGeocode / 좌표 기준 시·도·시군 / 2026-11-04"
|
||||
},
|
||||
{
|
||||
"sample": 7,
|
||||
"types": "π형",
|
||||
"lon": 126.95458694870382,
|
||||
"lat": 35.9875667117768,
|
||||
"crs": "EPSG:5186",
|
||||
"surveyX": 376666.02111800003,
|
||||
"surveyY": 195904.74905749998,
|
||||
"modelX": 195904.74905749998,
|
||||
"modelY": 376666.02111800003,
|
||||
"basis": "P1 IP1·IP3 중점",
|
||||
"crsAssumed": true,
|
||||
"address": "전북특별자치도 익산시",
|
||||
"coordinateConvention": "Excel X=N/Y=E; model x=E/y=N; ground X=N/Y=E. One swap on import.",
|
||||
"addressSource": "ArcGIS reverseGeocode / 좌표 기준 시·도·시군 / 2026-11-04"
|
||||
},
|
||||
{
|
||||
"sample": 8,
|
||||
"types": "T형",
|
||||
"lon": 127.08479100137305,
|
||||
"lat": 36.467976678606234,
|
||||
"crs": "EPSG:5186",
|
||||
"surveyX": 429976.25170699996,
|
||||
"surveyY": 207599.6468555,
|
||||
"modelX": 207599.6468555,
|
||||
"modelY": 429976.25170699996,
|
||||
"basis": "P1 IP1·IP3 중점",
|
||||
"crsAssumed": true,
|
||||
"address": "충청남도 공주시",
|
||||
"coordinateConvention": "Excel X=N/Y=E; model x=E/y=N; ground X=N/Y=E. One swap on import.",
|
||||
"addressSource": "ArcGIS reverseGeocode / 좌표 기준 시·도·시군 / 2026-11-04"
|
||||
},
|
||||
{
|
||||
"sample": 9,
|
||||
"types": "T형",
|
||||
"lon": 127.09529509409472,
|
||||
"lat": 36.4511219864682,
|
||||
"crs": "EPSG:5186",
|
||||
"surveyX": 428106.8065885,
|
||||
"surveyY": 208542.956375,
|
||||
"modelX": 208542.956375,
|
||||
"modelY": 428106.8065885,
|
||||
"basis": "P1 IP1·IP3 중점",
|
||||
"crsAssumed": true,
|
||||
"address": "충청남도 공주시",
|
||||
"coordinateConvention": "Excel X=N/Y=E; model x=E/y=N; ground X=N/Y=E. One swap on import.",
|
||||
"addressSource": "ArcGIS reverseGeocode / 좌표 기준 시·도·시군 / 2026-11-04"
|
||||
},
|
||||
{
|
||||
"sample": 10,
|
||||
"types": "T형",
|
||||
"lon": 127.01123265451423,
|
||||
"lat": 37.85313626519123,
|
||||
"crs": "EPSG:5186",
|
||||
"surveyX": 583698.9063405,
|
||||
"surveyY": 200988.555986,
|
||||
"modelX": 200988.555986,
|
||||
"modelY": 583698.9063405,
|
||||
"basis": "P1 IP1·IP3 중점",
|
||||
"crsAssumed": true,
|
||||
"address": "경기도 양주시",
|
||||
"coordinateConvention": "Excel X=N/Y=E; model x=E/y=N; ground X=N/Y=E. One swap on import.",
|
||||
"addressSource": "ArcGIS reverseGeocode / 좌표 기준 시·도·시군 / 2026-11-04"
|
||||
},
|
||||
{
|
||||
"sample": 11,
|
||||
"types": "π형",
|
||||
"lon": 127.30352772437956,
|
||||
"lat": 37.032385581196664,
|
||||
"crs": "EPSG:5186",
|
||||
"surveyX": 492650.1232015,
|
||||
"surveyY": 227006.08240850002,
|
||||
"modelX": 227006.08240850002,
|
||||
"modelY": 492650.1232015,
|
||||
"basis": "P1 IP1·IP3 중점",
|
||||
"crsAssumed": true,
|
||||
"address": "경기도 안성시",
|
||||
"coordinateConvention": "Excel X=N/Y=E; model x=E/y=N; ground X=N/Y=E. One swap on import.",
|
||||
"addressSource": "ArcGIS reverseGeocode / 좌표 기준 시·도·시군 / 2026-11-04"
|
||||
},
|
||||
{
|
||||
"sample": 12,
|
||||
"types": "π형",
|
||||
"lon": 127.16447397444153,
|
||||
"lat": 37.524162320830534,
|
||||
"crs": "EPSG:5186",
|
||||
"surveyX": 547198.543959,
|
||||
"surveyY": 214539.001821,
|
||||
"modelX": 214539.001821,
|
||||
"modelY": 547198.543959,
|
||||
"basis": "P1 IP1·IP3 중점",
|
||||
"crsAssumed": true,
|
||||
"address": "경기도 하남시",
|
||||
"coordinateConvention": "Excel X=N/Y=E; model x=E/y=N; ground X=N/Y=E. One swap on import.",
|
||||
"addressSource": "ArcGIS reverseGeocode / 좌표 기준 시·도·시군 / 2026-11-04"
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,2 @@
|
||||
export const SAMPLE_MAP_CONFIG={initialZoom:7.3,initialCentre:[127.6,36] as [number,number],selectionZoom:14,locationZoom:17,minZoom:5,streetMaxZoom:19,satelliteMaxZoom:23};
|
||||
export const mercator=(lon:number,lat:number):[number,number]=>[(lon+180)/360*256,(1-Math.log(Math.tan(Math.PI/4+Math.max(-85,Math.min(85,lat))*Math.PI/360))/Math.PI)/2*256];
|
||||
@@ -0,0 +1,45 @@
|
||||
import { sampleProjects } from './sample-projects';
|
||||
import { sampleLocations } from './sample-locations';
|
||||
import { mercator, SAMPLE_MAP_CONFIG as C } from './sample-map-model';
|
||||
export function createSampleMap(panel:HTMLElement,onSelect:(id:number)=>void,getSelected:()=>number,isBusy:()=>boolean){
|
||||
const map=panel.querySelector<HTMLElement>('.sample-map')!,tiles=map.querySelector<HTMLElement>('.sample-map-tiles')!,markers=map.querySelector<HTMLElement>('.sample-map-markers')!,error=map.querySelector<HTMLElement>('.sample-map-error')!;
|
||||
let mode='street',zoom=C.initialZoom,centre=mercator(...C.initialCentre),hover:number|null=null,animation=0,renderFrame=0,flying=false;
|
||||
const max=()=>mode==='street'?C.streetMaxZoom:C.satelliteMaxZoom;
|
||||
const draw=()=>{
|
||||
renderFrame=0;const w=map.clientWidth,h=map.clientHeight;if(!w||!h)return;zoom=Math.min(max(),Math.max(C.minZoom,zoom));map.dataset.zoom=zoom.toFixed(2);map.dataset.maxZoom=String(max());
|
||||
const scale=2**zoom,z=Math.min(max(),Math.ceil(zoom)),ratio=scale/2**z,tile=256*ratio,cx=centre[0]*scale,cy=centre[1]*scale,x0=Math.floor((cx-w/2)/tile),y0=Math.floor((cy-h/2)/tile),used=new Set<string>();
|
||||
if(flying){for(const img of tiles.querySelectorAll<HTMLImageElement>('img')){const [,oldZ,oldX,oldY]=img.dataset.tile!.split('-').map((v,i)=>i?Number(v):v) as [string,number,number,number];const t=256*scale/2**oldZ;img.style.cssText=`left:${oldX*t-cx+w/2}px;top:${oldY*t-cy+h/2}px;width:${t+.3}px;height:${t+.3}px`;}}
|
||||
else{
|
||||
for(let x=x0;x*tile<cx+w/2;x++)for(let y=y0;y*tile<cy+h/2;y++){
|
||||
if(x<0||y<0||x>=2**z||y>=2**z)continue;const key=`${mode}-${z}-${x}-${y}`;used.add(key);let img=tiles.querySelector<HTMLImageElement>(`[data-tile="${key}"]`);
|
||||
if(!img){img=new Image();img.dataset.tile=key;img.alt='';img.draggable=false;img.src=mode==='street'?`https://tile.openstreetmap.org/${z}/${x}/${y}.png`:`https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${z}/${y}/${x}`;img.onerror=()=>{if(img?.isConnected){error.hidden=false;error.textContent=z>18?'이 지역의 고배율 타일을 표시할 수 없습니다. 축소해 주세요.':'지도 연결 실패 · 좌표 마커 표시';}};img.onload=()=>{error.hidden=true;};tiles.append(img);}
|
||||
img.style.cssText=`left:${x*tile-cx+w/2}px;top:${y*tile-cy+h/2}px;width:${tile+.3}px;height:${tile+.3}px`;
|
||||
}for(const image of tiles.querySelectorAll<HTMLImageElement>('img'))if(!used.has(image.dataset.tile!))image.remove();
|
||||
}
|
||||
panel.querySelector<HTMLElement>('.sample-map-attribution')!.innerHTML=mode==='street'?'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap</a>':'Tiles © Esri · Vantor, Earthstar Geographics';
|
||||
const points=sampleLocations.filter(l=>l.lon!==null&&l.lat!==null).map(l=>{const p=mercator(l.lon!,l.lat!);return{id:l.sample,x:(p[0]-centre[0])*scale+w/2,y:(p[1]-centre[1])*scale+h/2};});
|
||||
markers.replaceChildren();const placed:[number,number][]=[];const lines=document.createElementNS('http://www.w3.org/2000/svg','svg');lines.style.cssText='position:absolute;inset:0;width:100%;height:100%;pointer-events:none';markers.append(lines);
|
||||
for(const point of points){
|
||||
let x=point.x,y=point.y;if(x>=0&&y>=0&&x<=w&&y<=h){for(let i=0;placed.some(p=>Math.hypot(p[0]-x,p[1]-y)<29)&&i<100;i++){const r=12+i*.85;x=point.x+Math.cos(i*2.39996)*r;y=point.y+Math.sin(i*2.39996)*r;}placed.push([x,y]);}
|
||||
if(Math.hypot(x-point.x,y-point.y)>1){const line=document.createElementNS('http://www.w3.org/2000/svg','line');for(const [k,v] of Object.entries({x1:point.x,y1:point.y,x2:x,y2:y}))line.setAttribute(k,String(v));line.setAttribute('stroke','#538da9');line.setAttribute('stroke-width','1');lines.append(line);}
|
||||
const b=document.createElement('button'),sample=sampleProjects.find(s=>s.sample===point.id)!;b.type='button';b.className='sample-marker';b.dataset.members=String(point.id);b.dataset.marker=String(point.id);b.style.left=`${x}px`;b.style.top=`${y}px`;b.textContent=String(point.id).padStart(2,'0');b.setAttribute('aria-label',`${sample.bridge} 선택`);b.title=sample.bridge;b.disabled=isBusy();b.addEventListener('click',()=>{if(!isBusy())onSelect(point.id);});markers.append(b);
|
||||
}highlight();
|
||||
};
|
||||
const schedule=()=>{if(!renderFrame)renderFrame=requestAnimationFrame(draw);};
|
||||
const highlight=()=>{markers.querySelectorAll<HTMLElement>('[data-members]').forEach(m=>{const ids=m.dataset.members!.split(',').map(Number);m.classList.toggle('is-selected',ids.includes(getSelected()));m.classList.toggle('is-hovered',hover!==null&&ids.includes(hover));});};
|
||||
const stop=()=>{cancelAnimationFrame(animation);flying=false;};
|
||||
const fly=(next:[number,number],targetZoom:number)=>{stop();flying=true;const from=[...centre] as [number,number],startZoom=zoom,start=performance.now();targetZoom=Math.min(max(),targetZoom);const tick=(now:number)=>{const t=Math.min(1,(now-start)/450),k=1-(1-t)**3;centre=[from[0]+(next[0]-from[0])*k,from[1]+(next[1]-from[1])*k];zoom=startZoom+(targetZoom-startZoom)*k;schedule();if(t<1)animation=requestAnimationFrame(tick);else{flying=false;schedule();}};animation=requestAnimationFrame(tick);};
|
||||
const focus=(id:number,detail=false)=>{
|
||||
const l=sampleLocations.find(l=>l.sample===id);if(!l||l.lon===null||l.lat===null)return;const selected=mercator(l.lon,l.lat);
|
||||
fly(selected,detail?C.locationZoom:C.selectionZoom);
|
||||
};
|
||||
const zoomAt=(delta:number,x=map.clientWidth/2,y=map.clientHeight/2)=>{stop();const next=Math.max(C.minZoom,Math.min(max(),zoom+delta)),oldScale=2**zoom,newScale=2**next;centre=[centre[0]+(x-map.clientWidth/2)*(1/oldScale-1/newScale),centre[1]+(y-map.clientHeight/2)*(1/oldScale-1/newScale)];zoom=next;schedule();};
|
||||
panel.querySelectorAll<HTMLButtonElement>('[data-map-mode]').forEach(b=>b.addEventListener('click',()=>{mode=b.dataset.mapMode!;zoom=Math.min(zoom,max());panel.querySelectorAll<HTMLButtonElement>('[data-map-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mapMode===mode)));stop();schedule();}));
|
||||
panel.querySelectorAll<HTMLButtonElement>('[data-zoom]').forEach(b=>b.addEventListener('click',()=>{const d=Number(b.dataset.zoom);if(d)zoomAt(d);else fly(mercator(...C.initialCentre),C.initialZoom);}));
|
||||
map.addEventListener('wheel',e=>{e.preventDefault();const r=map.getBoundingClientRect();zoomAt(e.deltaY<0?.5:-.5,e.clientX-r.left,e.clientY-r.top);},{passive:false});
|
||||
map.addEventListener('dblclick',e=>{if((e.target as Element).closest('button'))return;const r=map.getBoundingClientRect();zoomAt(1,e.clientX-r.left,e.clientY-r.top);});
|
||||
let drag:{x:number;y:number;c:[number,number]}|null=null;
|
||||
map.addEventListener('pointerdown',e=>{if((e.target as Element).closest('button,a'))return;stop();drag={x:e.clientX,y:e.clientY,c:[...centre]};map.setPointerCapture(e.pointerId);});map.addEventListener('pointermove',e=>{if(!drag)return;centre=[drag.c[0]-(e.clientX-drag.x)/2**zoom,drag.c[1]-(e.clientY-drag.y)/2**zoom];schedule();});map.addEventListener('pointerup',()=>{drag=null;});map.addEventListener('pointercancel',()=>{drag=null;});
|
||||
new ResizeObserver(schedule).observe(map);schedule();
|
||||
return {focus,refresh:schedule,highlight,hover:(id:number|null)=>{hover=id;highlight();}};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { buildPier } from '../src/core/geometry';
|
||||
import { buildAllPiers } from '../src/view/all-piers';
|
||||
import { terrainGeometry } from '../src/view/terrain';
|
||||
import { buildTerrainMapGroup, setTerrainMapMode, disposeTerrainMapGroup } from '../src/view/terrain-map';
|
||||
import { sampleResource } from './sample-resource';
|
||||
import type { Sample } from './sample-explorer';
|
||||
const disposers=new WeakMap<HTMLElement,()=>void>();
|
||||
export async function renderSamplePreview(host:HTMLElement,sample:Sample,current:()=>boolean):Promise<void>{
|
||||
disposers.get(host)?.();host.replaceChildren();host.dataset.sample=String(sample.sample);host.dataset.previewState='loading';
|
||||
const note=document.createElement('span');note.className='sample-preview-note';note.textContent='실제 지도·지반·교각을 불러오는 중…';host.append(note);
|
||||
let disposed=false,renderer:THREE.WebGLRenderer|undefined,controls:OrbitControls|undefined,maps:THREE.Group|null=null,frame=0,resize:ResizeObserver|undefined;
|
||||
const scene=new THREE.Scene();let ground:THREE.Mesh|null=null;
|
||||
const dispose=()=>{disposed=true;cancelAnimationFrame(frame);resize?.disconnect();host.classList.remove('is-expanded');controls?.dispose();disposeTerrainMapGroup(maps);maps?.removeFromParent();scene.traverse(o=>{const m=o as THREE.Mesh;m.geometry?.dispose();(Array.isArray(m.material)?m.material:[m.material]).forEach(v=>v?.dispose());});renderer?.dispose();renderer?.forceContextLoss();};
|
||||
disposers.set(host,dispose);
|
||||
try{
|
||||
const {project}=await sampleResource(sample);if(!current()||disposed)return;
|
||||
renderer=new THREE.WebGLRenderer({antialias:true,alpha:true});renderer.setPixelRatio(Math.min(devicePixelRatio,1.5));renderer.localClippingEnabled=true;renderer.setClearColor(0xeef5fb,1);renderer.domElement.setAttribute('aria-label',`${sample.bridge} 실제 지도·지반·교각 3D 미리보기`);renderer.domElement.tabIndex=0;host.onkeydown=e=>{if(e.key==='Escape')host.classList.remove('is-expanded');};host.prepend(renderer.domElement);
|
||||
const entries=project.piers.flatMap(p=>{try{return[{name:p.input.name,model:buildPier(p.input)}];}catch{return[];}}),grid=project.groundModel?.terrainGrid;
|
||||
const corner=project.groundModel?.mapLayers?.[0]?.survey_corners[0];const pivot=entries[0]?.model.pivot??{e:grid?.origin[1]??corner?.[1]??0,n:grid?.origin[0]??corner?.[0]??0};let focus:THREE.Object3D=scene,representative:THREE.Box3|null=null;
|
||||
if(entries.length){const all=buildAllPiers(entries,grid);scene.add(all.piers);focus=all.piers;ground=all.terrain;if(entries.length>2){all.piers.updateWorldMatrix(true,true);representative=new THREE.Box3();for(const entry of entries.slice(0,2))representative.union(new THREE.Box3().setFromObject(all.scenes.get(entry.name)!.root));}}
|
||||
else if(grid)ground=new THREE.Mesh(terrainGeometry(grid,pivot),new THREE.MeshStandardMaterial({color:0xb5c7ad,side:THREE.DoubleSide}));
|
||||
if(ground){scene.add(ground);(ground.material as THREE.MeshStandardMaterial).opacity=1;}
|
||||
const layers=project.groundModel?.mapLayers??[],hasMap=layers.length>0;
|
||||
const box=new THREE.Box3().setFromObject(focus);if(box.isEmpty()&&grid)box.setFromObject(ground!);
|
||||
const elevation=entries[0]?.model.groundEl??(box.isEmpty()?0:box.getCenter(new THREE.Vector3()).y);
|
||||
if(hasMap){maps=buildTerrainMapGroup(layers,pivot,elevation,layers.some(l=>l.id==='satellite')?'satellite':'ground',()=>{if(!disposed)note.textContent='저장 지도 이미지 오류 · 지반/교각 표시';},grid);scene.add(maps);if(box.isEmpty())box.setFromObject(maps);}
|
||||
if(box.isEmpty())throw new Error('표시할 교각·지반·지도 좌표가 없습니다.');
|
||||
host.dataset.mapLayers=String(layers.length);host.dataset.terrain=String(!!grid);host.dataset.pierCount=String(entries.length);
|
||||
note.textContent=`${hasMap?'저장 지도':'지도 미연결'} · ${grid?'원도면 지반':'지형정보 미연결'} · 교각 ${entries.length}기${entries.length>2?' · 대표구간':''}`;
|
||||
const fullBox=box.clone();if(representative)box.copy(representative);const centre=box.getCenter(new THREE.Vector3()),radius=Math.max(box.getSize(new THREE.Vector3()).length()/2,1),camera=new THREE.PerspectiveCamera(38,2,.1,Math.max(radius,fullBox.getSize(new THREE.Vector3()).length())*25);
|
||||
const first=entries[0]?.model.pivot,last=entries.at(-1)?.model.pivot,axis=first&&last?new THREE.Vector3(last.e-first.e,0,first.n-last.n):new THREE.Vector3(1,0,0);if(axis.length()<1)axis.set(1,0,0);axis.normalize();
|
||||
camera.position.copy(centre).add(new THREE.Vector3(axis.z,1.25,-axis.x).normalize().multiplyScalar(radius*3.6));
|
||||
controls=new OrbitControls(camera,renderer.domElement);controls.target.copy(centre);controls.enableDamping=true;controls.minDistance=radius*.05;controls.maxDistance=Math.max(radius,fullBox.getSize(new THREE.Vector3()).length())*12;controls.update();
|
||||
let currentBox=box;const direction=new THREE.Vector3(axis.z,1.25,-axis.x).normalize();
|
||||
const fitBox=(target:THREE.Box3)=>{currentBox=target;const c=target.getCenter(new THREE.Vector3()),r=Math.max(target.getSize(new THREE.Vector3()).length(),1);camera.position.copy(c).addScaledVector(direction,r*4);camera.lookAt(c);camera.updateMatrixWorld();const bounds=new THREE.Box3();for(const x of [target.min.x,target.max.x])for(const y of [target.min.y,target.max.y])for(const z of [target.min.z,target.max.z])bounds.expandByPoint(new THREE.Vector3(x,y,z).applyMatrix4(camera.matrixWorldInverse));const size=bounds.getSize(new THREE.Vector3()),tan=Math.tan(camera.fov*Math.PI/360),distance=(Math.max(size.x/(2*tan*camera.aspect),size.y/(2*tan))+size.z/2)*1.14;camera.position.copy(c).addScaledVector(direction,distance);controls!.target.copy(c);controls!.update();};
|
||||
scene.add(new THREE.HemisphereLight(0xffffff,0x7b91a7,2));const light=new THREE.DirectionalLight(0xffffff,2);light.position.set(200,400,100);scene.add(light);
|
||||
const tools=document.createElement('div');tools.className='sample-preview-tools';
|
||||
for(const [label,action] of [['확대',()=>{host.classList.toggle('is-expanded');}],['위성',()=>setTerrainMapMode(maps,'satellite')],['2D',()=>setTerrainMapMode(maps,'ground')],['지반 투명',()=>{maps?.traverse(o=>{if(o instanceof THREE.Mesh){o.material.transparent=!o.material.transparent;o.material.opacity=o.material.transparent ? .45 : 1;o.material.depthWrite=!o.material.transparent;}});if(ground)(ground.material as THREE.Material).visible=!(ground.material as THREE.Material).visible;}],['전체',()=>fitBox(fullBox)],['맞춤',()=>fitBox(box)]] as const){
|
||||
const b=document.createElement('button');b.type='button';b.textContent=label;b.disabled=!hasMap&&['위성','2D'].includes(label);b.addEventListener('click',action);tools.append(b);
|
||||
}host.append(tools);
|
||||
resize=new ResizeObserver(()=>{if(disposed||!renderer)return;const w=host.clientWidth,h=host.clientHeight;if(w&&h){renderer.setSize(w,h,false);camera.aspect=w/h;camera.updateProjectionMatrix();fitBox(currentBox);}});resize.observe(host);
|
||||
host.dataset.previewState='ready';const render=()=>{if(disposed)return;frame=requestAnimationFrame(render);if(host.getClientRects().length&&!document.hidden){controls!.update();renderer!.render(scene,camera);}};render();
|
||||
}catch(error){if(current()&&!disposed){host.dataset.previewState='error';note.textContent=`미리보기 오류: ${(error as Error).message}`;}}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { parseProject } from '../src/core/project';
|
||||
import type { Sample } from './sample-explorer';
|
||||
const resources=new Map<string,Promise<{text:string;project:ReturnType<typeof parseProject>}>>();
|
||||
/** One immutable resource per sample URL, shared by preview and existing app-file loader. */
|
||||
export function sampleResource(sample:Pick<Sample,'url'|'bridge'|'pierCount'>){
|
||||
if(!resources.has(sample.url))resources.set(sample.url,(async()=>{
|
||||
const response=await fetch(sample.url);if(!response.ok)throw new Error(`샘플 파일 읽기 실패: HTTP ${response.status}`);
|
||||
const text=await response.text(),project=parseProject(text);
|
||||
if(project.project.bridge!==sample.bridge)throw new Error(`교량 ID 불일치: 선택 ${sample.bridge} / 파일 ${project.project.bridge}`);
|
||||
if(project.piers.length!==sample.pierCount)throw new Error(`교각 수 불일치: 목록 ${sample.pierCount} / 파일 ${project.piers.length}`);
|
||||
return {text,project};
|
||||
})().catch(error=>{resources.delete(sample.url);throw error;}));
|
||||
return resources.get(sample.url)!;
|
||||
}
|
||||
Reference in new issue
Block a user