Update landing page and ground link features

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
CopilotandCopilot committed 2026-11-05 11:32:50 +09:00
1 parent a5cc458252
commit 7efe9097ce
28 files changed
+928 -120

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
# 프로그램 내부 지반 생성 팝업
현재 지반 생성 버튼은 별도 Windows 창 대신 프로그램 내부 dialog를 연다.
- Excel 시작: 현재 Excel이 연결되며 종평면도 DWG/DXF를 선택한 뒤 지반 생성을 누른다.
- 도면 시작: 추출 폴더의 종평면도 원본과 Excel이 연결되며 지반 생성을 누른다.
- 입력을 바꾸려면 팝업의 파일 선택을 사용한다. Excel 연결 정보가 없는 프로젝트는 Excel도 선택한다.
- 기존 Python build_from_files 엔진을 창 없이 실행한다. BAT를 브라우저 안에 직접 삽입하는 방식이 아니며, 독립 실행용 BAT는 유지한다.
- 진행 로그와 오류를 팝업에 표시한다. 닫아도 작업은 계속되며 다시 열 수 있다.
- 완료 결과는 기존 JSON 로더에 전달한다. 지반·지층 패널을 벗어나면 복귀 후 반영한다.
- 결과는 data/output/app_<작업 ID>에 저장한다. 원본 자료는 변경하지 않는다.
- Windows 로컬 Vite 서버가 필요하다. 정적 호스팅에서는 실행할 수 없다.
검증: TypeScript 검사 및 기존 후보 선택 테스트 3건 통과. 브라우저에서 실제 prepare API, 팝업 표시, 입력 누락 오류, 닫기 및 즉시 재열기 확인. 이번 팝업 수정에서 새 도면 전체 생성은 실행하지 않았다.
주요 파일: ground-link.ts, ground-link-server.ts, ground-popup.css, pierzainer_ground/web_job.py. 기존 main/landing 입력 연결을 재사용한다.
+59
View File
@@ -0,0 +1,59 @@
import { spawn } from 'node:child_process';
import { mkdir,writeFile,readFile,readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import type { Plugin } from 'vite';
export const GROUND_LINK_API='/__pierzainer/ground-link';
export function startGroundBat(bat:string,context:string,cwd:string){
return spawn(process.env.ComSpec??'cmd.exe',['/d','/s','/c',`""${bat}""`],{cwd,windowsHide:true,windowsVerbatimArguments:true,env:{...process.env,PIERZAINER_GROUND_CONTEXT:context},stdio:'ignore'});
}
export async function findGroundDrawing(folder:string):Promise<string|undefined>{
const matches:string[]=[];
const walk=async(dir:string,depth:number)=>{for(const entry of await readdir(dir,{withFileTypes:true})){const file=path.join(dir,entry.name);if(entry.isDirectory()&&depth<2)await walk(file,depth+1);else if(entry.isFile()&&/\.(dwg|dxf)$/i.test(entry.name)&&/종평면|종평|종단|평종면/.test(entry.name))matches.push(file);}};
if(folder)await walk(folder,0);const originals=matches.filter(file=>/\.dwg$/i.test(file));return originals.length===1?originals[0]:matches.length===1?matches[0]:undefined;
}
export function groundLinkPlugin(root:string):Plugin{
const base=path.join(root,'pierzainer_ground'),cache=path.join(base,'data/cache/app-link'),stages=new Map<string,{excel:string;folder:string;name:string}>(),jobs=new Map<string,{output:string;logs:string;state:string}>();
const handler=async(req:any,res:any,next:()=>void)=>{
const pathname=new URL(req.url??'/','http://localhost').pathname;if(!pathname.startsWith(GROUND_LINK_API+'/'))return next();
const reply=(code:number,value:unknown)=>{res.writeHead(code,{'Content-Type':'application/json; charset=utf-8','Cache-Control':'no-store'});res.end(JSON.stringify(value));};
const host=req.headers.host??'',address=req.socket.remoteAddress??'';
if(req.method!=='POST'||! /^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i.test(host)||!['127.0.0.1','::1','::ffff:127.0.0.1'].includes(address)||req.headers['x-pierzainer-action']!=='ground-link'||req.headers.origin&&req.headers.origin!==`http://${host}`&&req.headers.origin!==`https://${host}`){reply(403,{error:'이 PC의 Pierzainer에서 실행하세요.'});return;}
try{
let raw='';for await(const part of req){raw+=part;if(raw.length>280*1024*1024)throw new Error('Excel 파일이 너무 큽니다(최대 20MB).');}const body=JSON.parse(raw||'{}'),action=pathname.slice(GROUND_LINK_API.length+1);
if(action==='stage'){
if(typeof body.name!=='string'||!body.name.toLowerCase().endsWith('.xlsx')||typeof body.base64!=='string'||!/^[A-Za-z0-9+/]*={0,2}$/.test(body.base64))throw new Error('교각 Excel(.xlsx) 파일을 선택하세요.');
const bytes=Buffer.from(body.base64,'base64');if(bytes.length>20*1024*1024||bytes[0]!==80||bytes[1]!==75)throw new Error('유효한 xlsx 파일이 아닙니다.');
const id=randomUUID(),dir=path.join(cache,id);await mkdir(dir,{recursive:true});const excel=path.join(dir,'piers.xlsx');await writeFile(excel,bytes);stages.set(id,{excel,name:body.name,folder:typeof body.folder==='string'?body.folder:''});reply(200,{id});return;
}
if(action==='prepare'){
const stage=stages.get(body.stageId);const drawing=stage?.folder?await findGroundDrawing(stage.folder):undefined;reply(200,{drawing:drawing??'',excelSelected:!!stage,excelName:stage?.name??''});return;
}
if(action==='upload'){
if(typeof body.name!=='string'||!/\.(dwg|dxf|xlsx)$/i.test(body.name)||typeof body.base64!=='string'||! /^[A-Za-z0-9+/]*={0,2}$/.test(body.base64))throw new Error('DWG/DXF 또는 Excel을 선택하세요.');
const bytes=Buffer.from(body.base64,'base64');if(bytes.length>200*1024*1024)throw new Error('최대 200MB입니다.');const dir=path.join(cache,randomUUID());await mkdir(dir,{recursive:true});const file=path.join(dir,path.basename(body.name));await writeFile(file,bytes);reply(200,{file});return;
}
if(action==='launch'){
const stage=stages.get(body.stageId),drawing=String(body.drawing??''),piers=String(body.excel??stage?.excel??'');
const original=stage?.folder?await findGroundDrawing(stage.folder):undefined;
const cached=(file:string)=>{const r=path.relative(cache,path.resolve(file));return !!r&&!r.startsWith('..')&&!path.isAbsolute(r);};
if(!existsSync(drawing)||!/\.(dwg|dxf)$/i.test(drawing)||drawing!==original&&!cached(drawing))throw new Error('종평면도를 선택하세요.');
if(!existsSync(piers)||!/\.xlsx$/i.test(piers)||!cached(piers))throw new Error('교각 Excel을 선택하세요.');
const id=randomUUID(),output=path.join(base,'data/output',`app_${id}`),job={output,logs:'',state:'running'};jobs.set(id,job);
const python=existsSync(path.join(base,'.venv/Scripts/python.exe'))?path.join(base,'.venv/Scripts/python.exe'):'python';
const child=spawn(python,['-B','-u','-X','utf8','-m','pierzainer_ground.web_job',drawing,piers,output],{cwd:base,windowsHide:true,stdio:['ignore','pipe','pipe']});
const append=(data:Buffer)=>{job.logs=(job.logs+data.toString('utf8')).slice(-200000);};child.stdout.on('data',append);child.stderr.on('data',append);
child.once('error',error=>{job.state='error';job.logs+='\n'+error.message;});child.once('close',code=>{job.state=code===0?'done':'error';});reply(200,{id});return;
}
if(action==='result'){
const job=jobs.get(body.id);if(!job)throw new Error('지반 생성 작업을 찾을 수 없습니다.');
if(job.state!=='done'){reply(200,{ready:false,state:job.state,logs:job.logs});return;}
for(const filename of ['ground_model_3d_with_maps.json','ground_model.json']){const file=path.join(job.output,filename);if(existsSync(file)){const text=await readFile(file,'utf8');JSON.parse(text);reply(200,{ready:true,filename,text,state:job.state,logs:job.logs});return;}}
reply(200,{ready:false});return;
}reply(404,{error:'지원하지 않는 지반 연동 요청입니다.'});
}catch(error){reply(400,{error:(error as Error).message});}
};
return{name:'pierzainer-ground-link',configureServer(server){server.middlewares.use((req,res,next)=>void handler(req,res,next));},configurePreviewServer(server){server.middlewares.use((req,res,next)=>void handler(req,res,next));}};
}
+11
View File
@@ -0,0 +1,11 @@
import { describe,it,expect } from 'vitest';
import { mkdir,writeFile } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { findGroundDrawing } from './ground-link-server';
const base=path.join(import.meta.dirname,'data/cache/link-tests');
describe('지반 프로그램 원본 종평면도 연결',()=>{
it('Excel 시작은 도면을 임의로 지정하지 않는다',async()=>expect(await findGroundDrawing('')).toBeUndefined());
it('폴더의 종평면도 원본 DWG를 선택하고 변환 DXF와 중복하지 않는다',async()=>{const dir=path.join(base,randomUUID());await mkdir(dir,{recursive:true});await writeFile(path.join(dir,'산외교 종평면도.dwg'),'');await writeFile(path.join(dir,'종평면도.dxf'),'');await writeFile(path.join(dir,'받침배치도.dwg'),'');expect(await findGroundDrawing(dir)).toBe(path.join(dir,'산외교 종평면도.dwg'));});
it('서로 다른 종평면도 원본이 여럿이면 사용자 선택으로 남긴다',async()=>{const dir=path.join(base,randomUUID());await mkdir(dir,{recursive:true});await writeFile(path.join(dir,'A교 종평면도.dwg'),'');await writeFile(path.join(dir,'B교 종평면도.dwg'),'');expect(await findGroundDrawing(dir)).toBeUndefined();});
});
+66
View File
@@ -0,0 +1,66 @@
import './ground-popup.css';
const API='/__pierzainer/ground-link';
let pending:{file:File;folder?:string}|undefined,staged:Promise<string|undefined>=Promise.resolve(undefined),version=0;
const request=async(action:string,body:object)=>{const r=await fetch(`${API}/${action}`,{method:'POST',headers:{'Content-Type':'application/json','X-Pierzainer-Action':'ground-link'},body:JSON.stringify(body)});const value=await r.json();if(!r.ok)throw new Error(value.error??'지반 프로그램 연동 실패');return value;};
const base64=(file:File)=>new Promise<string>((resolve,reject)=>{const reader=new FileReader();reader.onerror=()=>reject(new Error('Excel 파일을 읽지 못했습니다.'));reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.readAsDataURL(file);});
document.addEventListener('change',event=>{const input=event.target as HTMLInputElement;if(input.id==='file'&&input.files?.[0])pending={file:input.files[0]};if(input.id==='project-file'){pending=undefined;version++;staged=Promise.resolve(undefined);}},true);
window.addEventListener('pierzainer:ground-source',(event:Event)=>{const d=(event as CustomEvent).detail;pending={file:new File([d.bytes],d.name,{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}),folder:d.folder};});
document.addEventListener('click',event=>{if(!(event.target as Element).closest('#dlg-apply')||!pending)return;const source=pending;pending=undefined;version++;staged=(async()=>{const data=await request('stage',{name:source.file.name,base64:await base64(source.file),folder:source.folder});return data.id as string;})();staged.catch(()=>{});},true);
let opening=false,running=false,launchSerial=0;
let popup:HTMLDialogElement;
function getPopup(){
if(popup)return popup;
popup=document.createElement('dialog');popup.className='ground-popup';popup.setAttribute('aria-labelledby','ground-popup-title');
popup.innerHTML=`<header><div><small>GROUND MODEL</small><h2 id="ground-popup-title">지반 모델 생성</h2></div><button type="button" data-close aria-label="닫기">×</button></header>
<p>종평면도와 교각 Excel로 지반면·위성사진·2D 지도 JSON을 생성합니다.</p>
<label for="ground-drawing-file">종평면도 DXF / DWG</label><div class="ground-file-row"><span data-drawing-name></span><button type="button" data-pick="drawing">종평면도 선택</button><input id="ground-drawing-file" data-drawing type="file" accept=".dwg,.dxf" hidden></div>
<label for="ground-excel-file">교각 Excel</label><div class="ground-file-row"><span data-excel-name></span><button type="button" data-pick="excel">Excel 선택</button><input id="ground-excel-file" data-excel type="file" accept=".xlsx" hidden></div>
<p class="ground-popup-note">기존 생성 엔진을 사용합니다. 지도 다운로드에는 인터넷 연결이 필요합니다.</p>
<button type="button" data-run>지반 생성</button><p data-status role="status" aria-live="polite"></p><pre data-logs tabindex="0" aria-label="생성 진행 로그"></pre><footer><button type="button" data-close>닫기</button></footer>`;
popup.querySelectorAll<HTMLButtonElement>('[data-pick]').forEach(button=>button.addEventListener('click',()=>popup.querySelector<HTMLInputElement>(`[data-${button.dataset.pick}]`)!.click()));
popup.querySelectorAll('[data-close]').forEach(button=>button.addEventListener('click',()=>popup.close()));document.body.append(popup);return popup;
}
window.addEventListener('pierzainer:open-ground',async()=>{
if(opening&&popup?.open)return;if(running&&popup){popup.showModal();opening=true;return;}opening=true;
const dialog=getPopup(),ticket=++launchSerial,snapshot=version;
const status=dialog.querySelector<HTMLElement>('[data-status]')!,logs=dialog.querySelector<HTMLElement>('[data-logs]')!,run=dialog.querySelector<HTMLButtonElement>('[data-run]')!;
const drawingInput=dialog.querySelector<HTMLInputElement>('[data-drawing]')!,excelInput=dialog.querySelector<HTMLInputElement>('[data-excel]')!;
drawingInput.value='';excelInput.value='';drawingInput.disabled=excelInput.disabled=false;dialog.querySelector('[data-drawing-name]')!.textContent='파일 확인 중…';dialog.querySelector('[data-excel-name]')!.textContent='파일 확인 중…';run.disabled=true;status.textContent='입력 파일 확인 중…';logs.textContent='';dialog.showModal();
let stageId:string|undefined,drawing='';
try{stageId=await staged;const prepared=await request('prepare',{stageId});drawing=prepared.drawing;
const display=(kind:string,name:string,connected:boolean)=>{dialog.querySelector(`[data-${kind}-name]`)!.textContent=name;dialog.querySelector(`[data-pick="${kind}"]`)!.textContent=connected?'파일 변경':kind==='drawing'?'종평면도 선택':'Excel 선택';};
const drawingName=drawing.split(/[\\/]/).pop()||'종평면도를 선택하세요.';const excelName=prepared.excelName||'교각 Excel을 선택하세요.';
display('drawing',drawingName,!!drawing);display('excel',excelName,prepared.excelSelected);
drawingInput.onchange=()=>display('drawing',drawingInput.files?.[0]?.name||drawingName,!!drawingInput.files?.[0]||!!drawing);
excelInput.onchange=()=>display('excel',excelInput.files?.[0]?.name||excelName,!!excelInput.files?.[0]||prepared.excelSelected);status.textContent='입력을 확인하고 지반 생성을 누르세요.';run.disabled=false;
}catch(error){status.textContent=(error as Error).message;opening=false;return;}
run.onclick=async()=>{
if(snapshot!==version){status.textContent='프로젝트가 변경되었습니다. 닫고 다시 열어 주세요.';return;}
run.disabled=true;drawingInput.disabled=excelInput.disabled=true;
try{
const upload=async(file:File)=>(await request('upload',{name:file.name,base64:await base64(file)})).file as string;
status.textContent='입력 파일 전달 중…';const chosen=drawingInput.files?.[0],excel=excelInput.files?.[0];
if(chosen)drawing=await upload(chosen);if(!drawing)throw new Error('종평면도를 선택하세요.');
const excelPath=excel?await upload(excel):undefined;
if(!excelPath&&!stageId)throw new Error('교각 Excel을 선택하세요.');
const job=await request('launch',{stageId,drawing,excel:excelPath});running=true;status.textContent='지반·지도 생성 중… 닫아도 생성은 계속됩니다.';
const deadline=Date.now()+30*60*1000;
const poll=async()=>{
if(snapshot!==version||ticket!==launchSerial){running=false;return;}
try{
const result=await request('result',{id:job.id});logs.textContent=result.logs??'';logs.scrollTop=logs.scrollHeight;
if(result.state==='error')throw new Error('지반 생성 실패. 진행 로그를 확인하세요.');
if(result.ready){
const input=document.querySelector<HTMLInputElement>('#ground-import');
status.textContent=input?'지반 생성 완료. 프로젝트에 불러왔습니다.':'지반 생성 완료. 지반·지층 화면으로 돌아오면 불러옵니다.';
if(input){const transfer=new DataTransfer();transfer.items.add(new File([result.text],result.filename,{type:'application/json'}));input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;return;}
}
if(Date.now()>deadline)throw new Error('응답 대기 시간이 지났습니다. 결과 폴더를 확인하세요.');setTimeout(poll,2000);
}catch(error){status.textContent=(error as Error).message;running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;}
};setTimeout(poll,1000);
}catch(error){status.textContent=(error as Error).message;running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;}
};
});
// Closing hides the panel; the current job and its polling continue.
document.addEventListener('close',event=>{if(event.target===popup)opening=false;},true);
+5
View File
@@ -0,0 +1,5 @@
.ground-popup{box-sizing:border-box;width:min(860px,94vw);max-height:90dvh;overflow:auto;border:1px solid #d2dfed;border-radius:16px;padding:26px;background:#fff;color:#14263d;box-shadow:0 24px 90px #10263f40;font-family:inherit}
.ground-popup::backdrop{background:#10263f70;backdrop-filter:blur(3px)}
.ground-popup header{display:flex;align-items:center;justify-content:space-between}.ground-popup h2{margin:6px 0 16px;font-size:23px}.ground-popup small{color:#087fcb;letter-spacing:.14em}
.ground-popup button{font:inherit;cursor:pointer;padding:9px 18px;border-radius:7px;border:1px solid #cbd9e8;background:#f2f7fc;color:#14263d}.ground-popup button:disabled{opacity:.5;cursor:wait}.ground-popup [data-run]{background:#087fcb;color:white;border-color:#087fcb;margin-top:18px}.ground-popup label{display:block;font-weight:600;margin-top:20px}.ground-popup input{display:block;margin-top:9px;max-width:100%;font:inherit}.ground-popup [data-drawing-name],.ground-popup [data-excel-name]{overflow-wrap:anywhere;font-size:12px;color:#64748b;margin-top:7px}.ground-popup-note{font-size:12px;color:#64748b}.ground-popup pre{height:210px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;background:#f5f8fc;border:1px solid #dce6f0;border-radius:8px;padding:12px;font-size:12px}.ground-popup footer{text-align:right}.ground-popup [data-status]{font-size:13px}
.ground-popup .ground-file-row{display:flex;align-items:center;gap:14px;margin-top:10px;padding:12px 14px;border:1px solid #dce6f0;border-radius:8px;background:#f8fafc}.ground-popup .ground-file-row span{flex:1;min-width:0;overflow-wrap:anywhere;font-size:14px;color:#14263d;margin:0}.ground-popup .ground-file-row button{flex-shrink:0}.ground-popup input[hidden]{display:none}
+25 -6
View File
@@ -1,6 +1,7 @@
"""Two-file, AI-free ground-model generator."""
from pathlib import Path
import os
import json
import queue
import re
import threading
@@ -10,16 +11,34 @@ from tkinter import filedialog, messagebox, scrolledtext, ttk
from .drawing_job import ROOT, build_from_files
def read_launch_context():
filename=os.environ.get('PIERZAINER_GROUND_CONTEXT')
if not filename:return {}
file=Path(filename).resolve()
if not file.is_relative_to((ROOT/'data/cache').resolve()):raise ValueError('지반 연동 설정 경로가 올바르지 않습니다.')
data=json.loads(file.read_text(encoding='utf-8'))
for key,extensions in [('drawing',('.dwg','.dxf')),('piers',('.xlsx',))]:
value=data.get(key,'')
if value and (not Path(value).is_file() or Path(value).suffix.lower() not in extensions):raise ValueError(f'{key} 입력 파일이 없습니다.')
if data.get('output') and not Path(data['output']).resolve().is_relative_to((ROOT/'data/output').resolve()):raise ValueError('결과 폴더는 지반 프로젝트 내부여야 합니다.')
if data.get('ready_file') and not Path(data['ready_file']).resolve().is_relative_to((ROOT/'data/cache').resolve()):raise ValueError('실행 확인 경로가 올바르지 않습니다.')
return data
class GroundModelApp(tk.Tk):
def __init__(self):
def __init__(self,context=None):
super().__init__()
self.title('Pierzainer 지반모델링 · 도면 + Excel 자동 생성 v2')
self.geometry('1000x640');self.minsize(850,540)
self.drawing=tk.StringVar();self.piers=tk.StringVar()
context=context or {};self._linked_output=context.get('output')
self.drawing=tk.StringVar(value=context.get('drawing',''));self.piers=tk.StringVar(value=context.get('piers',''))
self.output=tk.StringVar(value=str(ROOT/'data/output'))
self.status=tk.StringVar(value='종평면도와 교각 Excel을 선택하세요')
self.messages=queue.Queue();self.busy=False
self._build();self.after(100,self._poll)
if self._linked_output or self.drawing.get():self._output_folder()
if context.get('message'):self.status.set(context['message'])
if context.get('ready_file'):Path(context['ready_file']).write_text('ready',encoding='utf-8')
def _build(self):
main=ttk.Frame(self,padding=16);main.pack(fill='both',expand=True)
@@ -28,12 +47,12 @@ class GroundModelApp(tk.Tk):
for row,key,label in ((1,'drawing','① 종평면도 DXF/DWG'),(2,'piers','② 교각 Excel')):
ttk.Label(main,text=label).grid(row=row,column=0,sticky='w',pady=6)
ttk.Entry(main,textvariable=getattr(self,key)).grid(row=row,column=1,sticky='ew',padx=10)
ttk.Button(main,text='찾기',command=lambda k=key:self._browse(k)).grid(row=row,column=2)
ttk.Button(main,text='종평면도 선택하기' if key=='drawing' else 'Excel 선택하기',command=lambda k=key:self._browse(k)).grid(row=row,column=2)
ttk.Label(main,text='결과 저장 위치').grid(row=3,column=0,sticky='w',pady=6)
ttk.Entry(main,textvariable=self.output,state='readonly').grid(row=3,column=1,columnspan=2,sticky='ew',padx=(10,0))
ttk.Label(main,text='두 파일만 선택하면 지반면 + 위성사진 + 2D 지도를 생성합니다. AI 연결 없이 OSM/Esri 인터넷 지도를 사용합니다.\n원본에 좌표계 표기가 없으면 프로젝트 기본 EPSG:5186을 적용하고, 좌표계 가정으로 표시합니다.',foreground='#4f6070',wraplength=920).grid(row=4,column=0,columnspan=3,sticky='w',pady=12)
buttons=ttk.Frame(main);buttons.grid(row=5,column=0,columnspan=3,sticky='ew',pady=6)
self.run_button=ttk.Button(buttons,text='지반 모델 자동 생성',command=self._start_run);self.run_button.pack(side='left')
self.run_button=ttk.Button(buttons,text='지반 생성',command=self._start_run);self.run_button.pack(side='left')
ttk.Button(buttons,text='결과 폴더 열기',command=self._open_out).pack(side='left',padx=10)
ttk.Label(main,textvariable=self.status).grid(row=6,column=0,columnspan=3,sticky='w',pady=6)
self.log=scrolledtext.ScrolledText(main,height=18,font=('Consolas',10))
@@ -52,7 +71,7 @@ class GroundModelApp(tk.Tk):
def _output_folder(self):
name=re.sub(r'[^\w.-]+','_',Path(self.drawing.get()).stem).strip('.') or 'new_drawing'
self.output.set(str(ROOT/'data/output'/('drawing_'+name)))
self.output.set(self._linked_output or str(ROOT/'data/output'/('drawing_'+name)))
def _open_out(self):
path=Path(self.output.get())
@@ -93,7 +112,7 @@ class GroundModelApp(tk.Tk):
def main():
app=GroundModelApp();app.mainloop();return 0
app=GroundModelApp(read_launch_context());app.mainloop();return 0
if __name__=='__main__':raise SystemExit(main())
@@ -0,0 +1,10 @@
"""Run the existing generator for the in-app dialog, without a native window."""
import sys
from .drawing_job import build_from_files
if __name__ == '__main__':
try:
build_from_files(sys.argv[1], sys.argv[2], lambda message: print(message, flush=True), output=sys.argv[3])
except Exception as error:
print(str(error), file=sys.stderr, flush=True)
sys.exit(1)
+29
View File
@@ -0,0 +1,29 @@
import os,json,unittest,uuid
from pathlib import Path
from unittest.mock import patch
from pierzainer_ground.app import read_launch_context,GroundModelApp,ROOT
class AppLinkTest(unittest.TestCase):
def setUp(self):
self.cache=ROOT/'data/cache'/('gui_link_test_'+uuid.uuid4().hex);self.cache.mkdir(parents=True)
self.excel=ROOT.parent/'도면추출/samples/샘플01_산외교_교각입력데이터.xlsx'
self.drawing=ROOT/'data/input/sanwoe/02.종평면도(울산)_2017.0619-수정.dwg'
def config(self,drawing=''):
return dict(piers=str(self.excel),drawing=drawing,output=str(ROOT/'data/output'/self.cache.name),ready_file=str(self.cache/'ready'))
def test_excel_has_empty_drawing_and_keeps_workbook(self):
file=self.cache/'context.json';file.write_text(json.dumps(self.config()),encoding='utf-8')
with patch.dict(os.environ,PIERZAINER_GROUND_CONTEXT=str(file)):
context=read_launch_context();self.assertEqual(context['drawing'],'');self.assertEqual(context['piers'],str(self.excel))
def test_native_gui_prefills_both_and_calls_existing_pipeline(self):
context=self.config(str(self.drawing));app=GroundModelApp(context);app.withdraw()
try:
self.assertEqual(app.drawing.get(),str(self.drawing));self.assertEqual(app.piers.get(),str(self.excel));self.assertEqual(app.output.get(),context['output']);self.assertEqual(app.run_button['text'],'지반 생성')
with patch('pierzainer_ground.app.build_from_files',return_value={'map_status':{'status':'mapped'}}) as run:
app._start_run();kind,message=app.messages.get(timeout=3);self.assertEqual(kind,'done');self.assertEqual(run.call_args.args[:2],(str(self.drawing),str(self.excel)));self.assertEqual(run.call_args.kwargs['output'],context['output'])
finally:
for token in app.tk.call('after','info'):app.after_cancel(token)
app.destroy()
def test_bad_output_is_rejected(self):
context=self.config();context['output']=str(ROOT.parent);file=self.cache/'context.json';file.write_text(json.dumps(context),encoding='utf-8')
with patch.dict(os.environ,PIERZAINER_GROUND_CONTEXT=str(file)),self.assertRaises(ValueError):read_launch_context()
if __name__=='__main__':unittest.main()
+1 -1
View File
@@ -50,7 +50,7 @@ export interface ColumnInput {
export function columnInputFor(model: PierModel, base: ColumnInput = COLUMN_P1): ColumnInput {
const col = model.columns[0];
const seg = model.segments.find((g) => col.s >= g.s0 - 1e-9 && col.s <= g.s1 + 1e-9) ?? model.segments[0];
const Hc = Math.round((model.levelBottom - model.footings[0].top) * 1000 * 10) / 10;
const Hc = Math.round((model.levelBottom - (model.footings[0]?.top ?? col.bottom)) * 1000 * 10) / 10;
const hcop = Math.round((seg.top - model.levelBottom) * 1000 * 10) / 10;
// C3 (상면 3단) = the lowest coping top −127 (C1 cover) −2·s_lay (상면 주철근 단 간격 input); P1 was 43.170 at 57.2.
// P1 top = C3 − 100 (계산 2026-10-23), so e_P1 = step top − (C3 − 0.1) = 485 on P1.
+7 -1
View File
@@ -557,6 +557,7 @@ function renderHeader(): void {
if (state.project.bridge) parts.push(state.project.bridge);
parts.push(`교각 ${state.piers.length}기`);
$('project-line').textContent = parts.join(' · ');
$('project-line').title = parts.join(' · ');
$('step1-status').textContent = pier?.model ? '생성됨' : '미생성';
$('step-drawing-status').textContent = pier?.model ? '가능' : '형상 필요';
renderSaveStatus();
@@ -2599,7 +2600,12 @@ function renderGroundModel(box: HTMLDivElement, pier: Pier): void {
importInput.value = '';
}
});
const importLabel = el('label', '지반 모델 파일 불러오기 (json)');
const groundLaunch = el('button', '지반 모델 파일 불러오기 · 지반 생성 프로그램');
groundLaunch.type = 'button';groundLaunch.className = 'btn';
groundLaunch.addEventListener('click', () => window.dispatchEvent(new Event('pierzainer:open-ground')));
wrap.appendChild(groundLaunch);
const linkStatus = el('p', '현재 Excel을 사용하여 종평면도에서 지반을 생성합니다.', 'muted small');linkStatus.id='ground-link-status';wrap.appendChild(linkStatus);
const importLabel = el('label', '기존 지반 JSON 직접 불러오기');
importLabel.appendChild(importInput);
wrap.appendChild(importLabel);
+15
View File
@@ -606,6 +606,21 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.top .brand strong { font-size: 18px; letter-spacing: 0.01em; }
.top .brand > span:not(.proj) { font-size: 12px; color: #b8c2ce; }
.proj { padding-left: 14px; border-left: 1px solid #33404f; color: #ffffff; font-size: 13px; font-weight: 700; }
.top { flex-wrap: nowrap; }
.top .brand { flex: 1 1 0; min-width: 0; align-items: center; }
.top .brand > strong, .top .brand > span:not(.proj) { flex-shrink: 0; white-space: nowrap; }
.top .proj { min-width: 0; overflow: hidden; overflow-wrap: anywhere; line-height: 1.5; max-height: 3em; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; }
.top .steps, .top .actions { flex: 0 0 auto; flex-wrap: nowrap; align-items: center; }
.top .steps .step, .top .actions button, .top .savest { white-space: nowrap; }
@media (max-width: 1600px) {
.top { display: grid; grid-template-columns: minmax(0, 1fr) auto; }
.top .actions { grid-column: 1 / -1; justify-content: flex-end; }
}
@media (max-width: 900px) {
.top { grid-template-columns: minmax(0, 1fr); }
.top .steps, .top .actions { max-width: 100%; overflow-x: auto; }
.top .actions { justify-content: flex-start; }
}
.steps .step { color: #dce3ea; min-height: 44px; padding: 4px 14px; border-radius: 6px; background: transparent; border: 0; }
.steps .step.current { background: #ffffff; color: #18212b; }
.steps .step em { font-style: normal; color: #b8c2ce; font-size: 12px; }
+2 -1
View File
@@ -1,8 +1,9 @@
import { groundLinkPlugin } from './pierzainer_ground/ground-link-server.ts';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
import { drawingLauncherPlugin } from './랜딩페이지/drawing-launcher.ts';
import { generalArrangementDwgPlugin } from './도면작성/dwg-server.ts';
export default defineConfig({
plugins: [drawingLauncherPlugin(fileURLToPath(new URL('.', import.meta.url))),generalArrangementDwgPlugin(fileURLToPath(new URL('.', import.meta.url)))],
plugins: [groundLinkPlugin(fileURLToPath(new URL('.', import.meta.url))),drawingLauncherPlugin(fileURLToPath(new URL('.', import.meta.url))),generalArrangementDwgPlugin(fileURLToPath(new URL('.', import.meta.url)))],
});
+2
View File
@@ -0,0 +1,2 @@
.cache/
+80
View File
@@ -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개 검사다.
+31
View File
@@ -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.
+1
View File
@@ -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')!;
+6 -5
View File
@@ -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
View File
@@ -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>&amp;</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 &amp; 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', () => {
+33
View File
@@ -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}}
+114
View File
@@ -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}}
+29
View File
@@ -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);
});
});
+48
View File
@@ -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);
}
+22
View File
@@ -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();}
}
+195
View File
@@ -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"
}
];
+2
View File
@@ -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];
+45
View File
@@ -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();}};
}
+46
View File
@@ -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}`;}}
}
+14
View File
@@ -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)!;
}