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