Improve drawing symbols and sample popup

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
CopilotandCopilot committed 2026-11-05 17:30:24 +09:00
1 parent 7efe9097ce
commit a0ec74b09b
11 files changed
+108 -21

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
# 받침 상세 문자치수와 치수표
상세 A의 A/B(받침 본체 평면), C(전·후열 중심 간격), D(일체 교좌면 길이), S(%), T/T1/T2(솔플레이트), h1/h2/h3/E(높이)를 문자 기호로 표시한다.
표는 기존 bearingSchedule의 현재 모델 계산값을 사용한다. A/B/C/T1/T2/E 치수 객체는 실제 mm 수치를 value로 유지하고 label만 문자로 표시한다. SVG와 DXF DIMENSION 문자 재정의에 같은 label을 적용한다. D는 일체 교좌면이 있는 경우만 표기한다. 미확인 제원은 값을 임의로 만들지 않는다. 첨부 참고 이미지의 3100, 1200, 50 등을 현재 모델의 실제값으로 복사하지 않는다.
기존 받침 번호와 EL.X, 높이·표고표를 유지한다. 수정 범위는 도면작성 폴더 내부다.
+25 -10
View File
@@ -98,7 +98,7 @@ export function bearingDetail(model:PierModel,scale:number,key?:string,dimension
}
const line=(a:Point,b:Point,layer:Entity['layer']='CENTER')=>entities.push({kind:'line',a,b,layer,source});
const text=(point:Point,t:string,height=1.8*scale,align:'left'|'center'|'right'='left')=>entities.push({kind:'text',point,text:t,height,align,layer:'TEXT',source});
const dim=(a:Point,b:Point,position:number,axis:'x'|'y')=>entities.push({kind:'dimension',a,b,position,axis,value:Math.abs(axis==='x'?a[0]-b[0]:a[1]-b[1]),height:1.7*scale,layer:'DIMENSION',source});
const dim=(a:Point,b:Point,position:number,axis:'x'|'y',label?:string)=>entities.push({kind:'dimension',a,b,position,axis,value:Math.abs(axis==='x'?a[0]-b[0]:a[1]-b[1]),height:1.7*scale,label,layer:'DIMENSION',source});
blocks.forEach((block,i)=>{
const x=(local[i].z-cx)*1000,z=(block.padTop-datum)*1000,idx=model.blocks.indexOf(block),s={member:'bearing-number',index:idx,pier:model.name};
entities.push({kind:'circle',center:[x,z+6*scale],radius:1.7*scale,layer:'TEXT',source:s});
@@ -114,19 +114,34 @@ export function bearingDetail(model:PierModel,scale:number,key?:string,dimension
['h1','h2','h3','T'].slice(0,zs.length-1).forEach((name,j)=>{
const yy=(zs[j]+zs[j+1])/2,target=(j*3-2)*scale;
line([x+sign*DIM.padSize*500,yy],[near,target],'TEXT');
text([near+sign*scale,target],`${name}=${value(zs[j+1]-zs[j])}`,1.55*scale,sign<0?'right':'left');
text([near+sign*scale,target],name,1.55*scale,sign<0?'right':'left');
});
}
});
if(pair&&dimensions){const a=(local[0].z-cx)*1000,b=(local[1].z-cx)*1000;dim([a,planY],[b,planY],planY-1000,'x');text([0,planY-1000-3*scale],'C · 전/후열 중심 간격',1.6*scale,'center');}
if(pair&&dimensions){const a=(local[0].z-cx)*1000,b=(local[1].z-cx)*1000;dim([a,planY],[b,planY],planY-1000,'x','C');}
const ib=model.integratedBlocks.find(b=>blocks.some(x=>x.row==='전열'&&x.no===b.frontNo));
if(ib)text([0,planY-1000-6*scale],`D=${value(INTEGRATED_BLOCK.length)} (일체 교좌면 길이)`,1.6*scale,'center');
const plan=selected.plan;
if(plan){
const p=solplateGeometry(selected,plan.h3,{A:plan.A,B:plan.B,margin:plan.margin,T:plan.T,slopePct:plan.S});
text([0,-650],`솔플 L=${value(p.L*1000)} / W=${value(p.W*1000)} · S=${plan.S.toFixed(3)}%`,1.6*scale,'center');
text([0,-800],`T=${value(plan.T)} / T1=${value(p.T1*1000)} / T2=${value(p.T2*1000)}`,1.6*scale,'center');
}else text([0,-650],'받침 본체·솔플레이트 제원 미확인',1.65*scale,'center');
if(ib&&dimensions)dim([-INTEGRATED_BLOCK.length/2,planY-600],[INTEGRATED_BLOCK.length/2,planY-600],planY-1000-5*scale,'x','D');
const rows=bearingSchedule(model);
if(dimensions)blocks.forEach((block,i)=>{
const row=rows.find(r=>r.index===model.blocks.indexOf(block))!,p=block.plan;
const x=(local[i].z-cx)*1000,y=(local[i].x-cy)*1000+planY;
if(!p)return;
// Symbolic dimensions retain measured numeric values for CAD and the schedule.
dim([x-p.A/2,y],[x+p.A/2,y],planY+700+3*scale,'x','A');
const side=i===0?-1:1;
dim([x,y-p.B/2],[x,y+p.B/2],x+side*(p.A/2+4*scale),'y','B');
const z=(block.padTop+p.h3/1000-datum)*1000;
const half=(row.L??p.A)/2;
if(row.E!==null)dim([x,(block.bottom-datum)*1000],[x,(block.bottom-datum)*1000+row.E],x+(i===0?-1:1)*(half+7*scale),'y','E');
for(const [symbol,edge,thickness] of [['T1',-1,row.T1],['T2',1,row.T2]] as const){
if(thickness!==null)dim([x+edge*half,z],[x+edge*half,z+thickness],x+edge*(half+2*scale),'y',symbol);
}
const slopeY=z+(row.T??0)+4*scale;
line([x-half,slopeY],[x+half,slopeY],'DIMENSION');
line([x+half,slopeY],[x+half-1.5*scale,slopeY+scale],'DIMENSION');
text([x,slopeY+scale],'S(%)',1.7*scale,'center');
});
text([0,-650],blocks.some(b=>!b.plan)?'받침 제원 미확인 · 치수표 참조':'문자 치수: 치수표 참조',1.4*scale,'center');
text([0,-1040],'교좌면 평면 · 가로 교축(n) / 세로 교직(s)',1.65*scale,'center');
return{entities,title:`상세 A · ${blocks.map(b=>`${b.no}(${b.row==='전열'?'전':'후'})`).join(' / ')}`};
}
+2 -2
View File
@@ -6,9 +6,9 @@ export function dimensionGraphics(entity:Extract<Entity,{kind:'dimension'}>):Ent
const p:Point=axis==='x'?[a[0],position]:[position,a[1]],q:Point=axis==='x'?[b[0],position]:[position,b[1]],u:Point=axis==='x'?[Math.sign(q[0]-p[0])||1,0]:[0,Math.sign(q[1]-p[1])||1];
const n:Point=[-u[1],u[0]],line=(a:Point,b:Point):Entity=>({kind:'line',a,b,source,layer});
const ext=(a:Point,b:Point):Entity=>{const d=Math.hypot(b[0]-a[0],b[1]-a[1]),k=d?1/d:0,dx=(b[0]-a[0])*k,dy=(b[1]-a[1])*k;return line([a[0]+dx*Math.min(CAD_STYLE.extensionGap*s,d),a[1]+dy*Math.min(CAD_STYLE.extensionGap*s,d)],[b[0]+dx*s,b[1]+dy*s]);};
const length=Math.hypot(q[0]-p[0],q[1]-p[1]),textWidth=formatMm(entity.value).length*height*.6,external=length<textWidth+2*arrow+2*s;
const length=Math.hypot(q[0]-p[0],q[1]-p[1]),textWidth=(entity.label??formatMm(entity.value)).length*height*.6,external=length<textWidth+2*arrow+2*s;
const out:Entity[]=[ext(a,p),ext(b,q),line(external?[p[0]-u[0]*3*arrow,p[1]-u[1]*3*arrow]:p,external?[q[0]+u[0]*3*arrow,q[1]+u[1]*3*arrow]:q)];
for(const point of [p,q])out.push({kind:'hatch',points:Array.from({length:16},(_,i)=>[point[0]+arrow*.5*Math.cos(i*Math.PI/8),point[1]+arrow*.5*Math.sin(i*Math.PI/8)] as Point),pattern:'SOLID',spacing:s,angle:0,color:'#ff0000',source,layer});
out.push({kind:'text',point:[(p[0]+q[0])/2+(axis==='y'?-1.2*s:0),(p[1]+q[1])/2+(axis==='x'?1.2*s:0)],text:formatMm(entity.value),height,rotation:axis==='y'?90:0,align:'center',source,layer});
out.push({kind:'text',point:[(p[0]+q[0])/2+(axis==='y'?-1.2*s:0),(p[1]+q[1])/2+(axis==='x'?1.2*s:0)],text:entity.label??formatMm(entity.value),height,rotation:axis==='y'?90:0,align:'center',source,layer});
return out;
}
+11
View File
@@ -129,6 +129,17 @@ describe('실제 모델 → 일반도',()=>{
expect(drawing.views.find(v=>v.id==='detail')!.entities.some(e=>e.source.member==='solplate')).toBe(true);
const cd=drawing.views.find(v=>v.id==='detail')!.entities.find(e=>e.kind==='dimension');
expect(cd?.kind==='dimension'?cd.value:null).toBeCloseTo(r.C!,6);
const detail=drawing.views.find(v=>v.id==='detail')!;
for(const key of ['A','B','C','T1','T2'] as const){
const dimension=detail.entities.find(e=>e.kind==='dimension'&&e.label===key);
expect(dimension?.kind==='dimension'?dimension.value:null).toBeCloseTo(r[key]!,6);
}
expect(detail.entities.some(e=>e.kind==='text'&&e.text==='S(%)')).toBe(true);
expect(detail.entities.some(e=>e.kind==='text'&&e.text==='h1')).toBe(true);
const symbolicDxf=exportDxf(drawing,layers);
expect(symbolicDxf).toContain('\r\n1\r\nT1\r\n');
expect(drawingSvg(drawing,layers,true)).toContain('T1');
const uneven={...input,bearings:input.bearings.filter(b=>!(b.row==='전열'&&b.no===5))},um=buildPier(uneven);
expect(bearingSchedule(um)).toHaveLength(uneven.bearings.length);
const ud=generateDrawing(um,uneven,project,{...DEFAULT_SETTINGS,detailBearing:'후열:10'});
+1 -1
View File
@@ -66,7 +66,7 @@ export function exportDxf(doc: DrawingDocument,visible: ReadonlySet<Layer>): str
const a=translate(e.a,offset),b2=translate(e.b,offset);
const definition:Point=e.axis==='x'?[b2[0],e.position+offset[1]]:[e.position+offset[0],b2[1]];
const mid:Point=e.axis==='x'?[(a[0]+b2[0])/2,definition[1]+e.height]:[definition[0]-e.height,(a[1]+b2[1])/2];
entities+=pairs(0,'DIMENSION')+common(e,modelRecord,view.scale)+pairs(100,'AcDbDimension',2,name,10,definition[0],20,definition[1],30,0,11,mid[0],21,mid[1],31,0,70,32,1,'<>',3,`PZ_${view.scale}`,42,e.value,
entities+=pairs(0,'DIMENSION')+common(e,modelRecord,view.scale)+pairs(100,'AcDbDimension',2,name,10,definition[0],20,definition[1],30,0,11,mid[0],21,mid[1],31,0,70,32,1,e.label??'<>',3,`PZ_${view.scale}`,42,e.value,
100,'AcDbAlignedDimension',13,a[0],23,a[1],33,0,14,b2[0],24,b2[1],34,0,50,e.axis==='x'?0:90,100,'AcDbRotatedDimension')+xdata(e,view.id);
}
const source={member:'view-title',index:0,pier:doc.pier};
+1 -1
View File
@@ -32,7 +32,7 @@ function dimensionLanes(view:DrawingView):void{
dims.sort((a,b)=>a.value-b.value);
for(const e of dims){
const a=e.axis==='x'?e.a[0]/s:shortenedY(e.a[1],view.breaks)/s,c=e.axis==='x'?e.b[0]/s:shortenedY(e.b[1],view.breaks)/s;
const lo=Math.min(a,c),hi=Math.max(a,c),width=textWidth(e.value.toLocaleString('en-US',{maximumFractionDigits:1}),CAD_STYLE.dimensionText),mid=(lo+hi)/2;
const lo=Math.min(a,c),hi=Math.max(a,c),width=textWidth(e.label??e.value.toLocaleString('en-US',{maximumFractionDigits:1}),CAD_STYLE.dimensionText),mid=(lo+hi)/2;
const item={lo,hi,textLo:mid-width/2-.6,textHi:mid+width/2+.6};
let lane=0;
while(lanes[lane]?.some(p=>Math.min(p.hi,item.hi)-Math.max(p.lo,item.lo)>.05||Math.min(p.textHi,item.textHi)-Math.max(p.textLo,item.textLo)>0))lane++;
+1 -1
View File
@@ -12,7 +12,7 @@ export type Entity = Base & (
{ kind: 'circle'; center: Point; radius: number } |
{ kind: 'hatch'; points:Point[]; holes?:Point[][];pattern:'SOLID'|'ANSI31'; spacing:number; angle:number; color?:string } |
{ kind: 'text'; point: Point; text: string; height: number; align?: 'left' | 'center' | 'right'; rotation?: number } |
{ kind: 'dimension'; a: Point; b: Point; position: number; axis: 'x' | 'y'; height: number; value: number }
{ kind: 'dimension'; a: Point; b: Point; position: number; axis: 'x' | 'y'; height: number; value: number; label?: string }
);
export type Bounds = { minX: number; minY: number; maxX: number; maxY: number };
export type Rect = { x: number; y: number; width: number; height: number };
+2 -2
View File
@@ -54,7 +54,7 @@ export function createLandingGuide(root: HTMLElement): {close:(restoreFocus?:boo
'종평면도·받침배치도·ELEVATION DWG가 들어 있는 폴더를 선택하고 자동 추출 시작을 누르세요. 진행 내용이 팝업에 표시됩니다.',
'결과 목록의 교각 불러오기를 눌러 Excel 인식 결과를 확인하고 형상을 생성합니다. Excel 저장으로 파일만 받을 수도 있습니다.'],
sample:['파일 준비 없이 실제 교각과 지반 모델을 살펴보려면 샘플 프로젝트를 선택하세요.',
'오른쪽 고정 패널에서 산외교·운곡교 등 12개 교량의 이름과 교각 수를 확인하고 원하는 교량을 선택합니다.',
'설계 라이브러리 팝업에서 산외교·운곡교 등 12개 교량의 이름과 교각 수를 확인하고 원하는 교량을 선택합니다.',
`현재 ${sampleProjects.filter(s=>s.pierCount>0).length}개 교량은 교각과 원도면 지반면, 위성사진, 2D 지도를 함께 엽니다. 교각 데이터가 없는 승천천교는 지반·지도 자료를 표시합니다. 설계 지반고는 Excel 원래 값을 유지하며, 기존 검증 알림은 확인이 필요합니다.`]
};
const facts: Record<StartMode,string[]>={
@@ -117,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);if(mode==='sample'){root.querySelector<HTMLButtonElement>('.sample-load')?.focus();return;}root.querySelector<HTMLButtonElement>(`[data-card="${mode}"] button`)?.click();
close(false);if(mode==='sample'){root.querySelector<HTMLButtonElement>('[data-card=sample] button')?.click();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']});
+11 -1
View File
@@ -5,6 +5,7 @@ import { createLandingGuide } from './landing-guide';
import { createHeroMotion, heroMotionMarkup } from './landing-motion';
import './landing-white.css';
import './landing-typography.css';
import './sample-library-popup.css';
import { createDrawingPopup, drawingPopupMarkup } from './drawing-popup';
const appRoot = document.querySelector<HTMLDivElement>('.app');
@@ -81,6 +82,10 @@ 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="M3 20V8l6-4 6 4 6-4v16H3zM9 4v16M15 8v12"/></svg></div><span class="landing-card-number">03 / EXPLORE</span></div><span class="landing-card-eyebrow">Design Library</span><h3>기설계교량 라이브러리</h3><p>기존 설계 프로젝트의 교각·지형·지도를 살펴보고 불러옵니다.</p></div>
<div class="landing-card-actions"><span class="landing-card-note">12개 설계 프로젝트</span><button class="landing-primary" type="button" aria-haspopup="dialog">설계 라이브러리 불러오기</button></div>
</article>
</section>
<footer class="landing-footer">
@@ -89,7 +94,7 @@ const landingPanelMarkup = `
</footer>
</section>
${sampleExplorerMarkup}
<dialog class="sample-library-popup" aria-label="기설계교량 라이브러리"><button type="button" class="sample-library-close" aria-label="설계 라이브러리 닫기">×</button>${sampleExplorerMarkup}</dialog>
<section id="landing-excel-panel" class="landing-panel" aria-label="엑셀 입력 패널">
<div class="landing-panel-head">
<h3>엑셀 입력</h3>
@@ -135,6 +140,7 @@ const closePanels = (): void => {
};
const showApp = (): void => {
landingOverlay.querySelector<HTMLDialogElement>('.sample-library-popup')?.close();
landingGuide.close(false);
if (appRoot) {
appRoot.hidden = false;
@@ -262,6 +268,10 @@ document.querySelectorAll<HTMLButtonElement>('[data-pick-file]').forEach((button
bindFilePicker(document.querySelector('[data-pick-file="excel"]') as HTMLButtonElement, 'landing-excel-input', 'landing-excel-name');
const libraryPopup=landingOverlay.querySelector<HTMLDialogElement>('.sample-library-popup')!;
landingOverlay.querySelector<HTMLButtonElement>('[data-card="sample"] button')!.addEventListener('click',()=>{landingGuide.close(false);landingGuide.dismissHint();if(!libraryPopup.open)libraryPopup.showModal();});
libraryPopup.querySelector('.sample-library-close')!.addEventListener('click',()=>libraryPopup.close());
libraryPopup.addEventListener('close',()=>{requestAnimationFrame(()=>{landingOverlay.querySelectorAll<HTMLElement>('.landing-shell,.landing-hero').forEach(element=>element.scrollTo(0,0));landingOverlay.querySelector<HTMLButtonElement>('[data-card=sample] button')?.focus({preventScroll:true});});});
createSampleExplorer(landingOverlay,(sample:Sample)=>loadSampleIntoApp(sample,showApp,showLanding));
if (homeButton) {
+3 -3
View File
@@ -6,10 +6,10 @@ 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-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-location"><div class="sample-location-heading"><strong>📍 <span class="sample-location-name"></span> 위치정보</strong></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><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>`;
@@ -38,7 +38,7 @@ export function createSampleExplorer(root:HTMLElement,load:(sample:Sample)=>Prom
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}기 · 실제 지반/지도 복원 완료`;}
+44
View File
@@ -0,0 +1,44 @@
.landing-white .landing-shell{grid-template-columns:minmax(0,1fr)}
.landing-white .landing-cards{grid-template-columns:repeat(3,minmax(0,1fr));width:min(1280px,92%)}
.landing-white .landing-card h3{font-size:clamp(16px,1.25vw,21px)}
.sample-library-popup{box-sizing:border-box;width:min(1240px,90vw);height:90dvh;max-width:94vw;max-height:94dvh;padding:0;border:1px solid #d6e5f1;border-radius:20px;background:#f8fbff;color:#14263d;overflow:hidden;box-shadow:0 30px 100px #14263d40}
.sample-library-popup::backdrop{background:#14263d66;backdrop-filter:blur(6px)}
.sample-library-close{position:absolute;right:22px;top:18px;z-index:10;width:38px;height:38px;border:1px solid #d6e5f1;border-radius:10px;background:white;color:#325670;font-size:24px;cursor:pointer}
.sample-library-popup .sample-explorer{height:100%;min-height:0;padding:24px;gap:18px 24px;grid-template-columns:minmax(0,1fr) minmax(300px,.48fr);grid-template-rows:auto minmax(0,1fr) minmax(0,1fr);border:0;box-shadow:none;background:linear-gradient(140deg,#fff,#f2f8fe)}
.sample-library-popup .sample-explorer-head{grid-column:1/-1;padding-right:62px;display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,360px) auto;gap:20px;align-items:center}
.sample-library-popup .sample-map-section{grid-column:1;grid-row:2}
.sample-library-popup .sample-selection{grid-column:1;grid-row:3;overflow:hidden;display:grid;grid-template-rows:auto minmax(0,1fr);gap:0}
.sample-library-popup .sample-list-section{grid-column:2;grid-row:2/4;min-height:0;border-top:0;border-left:1px solid #dce6f0;padding:0 0 0 24px;grid-template-rows:auto minmax(0,1fr) auto;gap:14px}
.sample-library-popup .sample-list{max-height:none;grid-auto-rows:64px;gap:8px;grid-template-columns:minmax(0,1fr)}
.sample-library-popup .sample-row{min-height:64px;padding:10px;gap:10px;grid-template-columns:28px minmax(0,1fr)}
.sample-library-popup .sample-row-number{width:28px;height:30px;font-size:11px}
.sample-library-popup .sample-row strong{font-size:14px}.sample-library-popup .sample-row small{font-size:11px;line-height:1.5}
.sample-library-popup .sample-selection .sample-preview{height:100%;min-height:0;flex:initial}
.sample-library-popup .sample-location{min-width:0;margin:0;padding:8px 10px;background:#eff6fc;border:0}.sample-library-popup .sample-location dl{margin:3px 0}.sample-library-popup .sample-coordinate-basis{display:none}
.sample-library-popup .sample-section-title h3{font-size:15px}
.sample-library-popup .sample-project-action{padding-top:14px;border-top:1px solid #dce6f0}
.sample-library-popup .sample-project-action .sample-load{min-height:46px;font-size:14px;margin-top:12px}
.sample-library-popup .sample-project-action .sample-selection-info h3{font-size:18px}
@media(max-width:900px){.sample-library-popup{width:96vw;max-width:96vw;height:94dvh}.sample-library-popup .sample-explorer{padding:18px;gap:12px;grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.sample-library-popup .sample-list-section{padding-left:12px}.sample-library-popup .sample-list{grid-template-columns:minmax(0,1fr)}.landing-white .landing-cards{gap:12px;width:95%}.landing-white .landing-card{padding:12px}}
@media(max-width:600px){.sample-library-popup{overflow:auto}.sample-library-popup .sample-explorer{height:auto;display:flex;flex-direction:column}.sample-library-popup .sample-map-section{height:260px;flex-shrink:0}.sample-library-popup .sample-selection{min-height:280px}.sample-library-popup .sample-list-section{height:420px;flex-shrink:0;border-left:0;padding-left:0}.sample-library-popup .sample-list{grid-template-columns:repeat(2,minmax(0,1fr))}.landing-white .landing-cards{grid-template-columns:1fr}}
@media(max-width:1100px){.sample-library-popup .sample-explorer-head{grid-template-columns:minmax(0,1fr) minmax(240px,300px) auto;gap:12px}.sample-library-popup .sample-explorer{grid-template-columns:minmax(0,1fr) 280px;gap:14px}.sample-library-popup .sample-list-section{padding-left:14px}.sample-library-popup .sample-explorer-head h2{font-size:18px}}
@media(max-width:700px){.sample-library-popup .sample-explorer-head{grid-template-columns:1fr auto}.sample-library-popup .sample-location{grid-column:1/-1;grid-row:2}.sample-library-popup .sample-explorer{grid-template-columns:minmax(0,1fr) 220px}}
@media(max-width:600px){.sample-library-popup .sample-selection{height:260px;min-height:260px;flex-shrink:0}}
/* Location details sit beside the library title, directly above the left map. */
@media(min-width:701px){
.sample-library-popup .sample-explorer-head{position:relative;display:flex;min-height:112px;align-items:flex-start;padding-top:12px}
.sample-library-popup .sample-explorer-head>div:first-child{max-width:310px}
.sample-library-popup .sample-explorer-head>b{margin-left:auto}
.sample-library-popup .sample-explorer-head .sample-location{position:absolute;left:29%;right:calc(32.5% + 24px);bottom:0;width:auto;padding:6px 9px}
}
/* Compact location overlay stays inside the map without consuming a grid row. */
.sample-library-popup .sample-explorer-head{min-height:0;align-items:center;padding-top:0}
.sample-library-popup .sample-map .sample-location{position:absolute;top:10px;left:10px;right:auto;bottom:auto;z-index:5;width:min(340px,calc(100% - 60px));padding:8px 10px;margin:0;border:1px solid #d6e5f1;border-radius:8px;background:#ffffffed;box-shadow:0 3px 12px #14263d14;pointer-events:none;grid-column:auto;grid-row:auto}
.sample-library-popup .sample-map .sample-location-heading{margin:0}
.sample-library-popup .sample-map .sample-location p{margin:3px 0}
.sample-library-popup .sample-map .sample-location{width:max-content;max-width:calc(100% - 60px);padding:7px 10px;background:rgba(248,252,255,.88);border:1px solid rgba(187,209,226,.7);border-radius:8px;box-shadow:0 2px 9px #234a6814;backdrop-filter:blur(6px);text-shadow:none}
.sample-library-popup .sample-map .sample-location dl{grid-template-columns:max-content minmax(0,1fr);column-gap:8px}