feat(B04/B05/공통): 7단계 명칭 통일, B05 패널 드래그 리사이즈, B04 계획선 레이어, 2D 지도 좌드래그 팬 제거

- 진행단계 오버레이 버튼과 B그룹 페이지 제목을 파일입력/전처리/종단설계/횡단설계/
  상세설계/수량산출/설계도서로 통일(ko/en). i18n 키는 유지하고 값만 교체.
  B05 페이지 제목의 하드코딩 문구를 locale 참조로 교체.
- ui_template_resizer 공통 스플리터 신설. B05 하단 종단 패널은 위쪽 경계를 끌어
  높이 조절(늘어난 만큼 그래프만 신축, 도면 테이블 높이는 고정), 안쪽 우측
  배수유역 패널은 왼쪽 경계를 끌어 폭 조절(상한 = 하단 패널 폭의 70%).
  크기는 sessionStorage에만 저장하고 CSS 변수로 적용해 접기 규칙과 충돌하지 않게 함.
- B04 2D 지도에 계획선 레이어 추가. 계획노선 좌표를 주는
  GET /api/projects/{id}/planned-route 신설(B03 업로드 CSV → 사업지 좌표계 m).
  선 색·굵기는 MapRender에 상수로 두고 B05 배수유역도와 공유.
- B04 지도 오버레이 재배치: 객체 표시 라벨은 우측 최하단, 배수유역 정보는 좌측 최상단.
- B04 지도·B05 배수유역도에서 좌버튼 드래그 팬 제거(가운데 버튼 전용).
  유역선 핸들·배관 마커 편집은 좌버튼 그대로 유지, 팬 중에만 grabbing 커서.
- B05 배수유역도에 위성사진 표시 토글 추가(등고선 앞, 기본 on).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 14:05:58 +09:00
co-authored by Claude Opus 5
parent ec873c6c0e
commit 9b6547a418
12 changed files with 411 additions and 37 deletions
+18 -13
View File
@@ -99,12 +99,12 @@ export const ui_locales = {
/* ---------------------------------------------------------------------------
* 워크플로우 공통 (B04~B09 상단 진행 단계 라벨)
* ------------------------------------------------------------------------ */
WF_Step_Surface: ["지표면 분석", "Surface Analysis"],
WF_Step_Route: ["경로 설계", "Route Design"],
WF_Step_ProfileCross: ["종횡단 생성", "Profile & Cross-section"],
WF_Step_DesignDetail: ["상세 설계", "Detailed Design"],
WF_Step_Quantity: ["수량 산출", "Quantity Takeoff"],
WF_Step_Estimation: ["견적 / 문서", "Estimation / Docs"],
WF_Step_Surface: ["전처리", "Preprocess"],
WF_Step_Route: ["종단설계", "Profile Design"],
WF_Step_ProfileCross: ["횡단설계", "Cross Design"],
WF_Step_DesignDetail: ["상세설계", "Detail Design"],
WF_Step_Quantity: ["수량산출", "Quantity"],
WF_Step_Estimation: ["설계도서", "Design Docs"],
WF_State_Stale: ["무효화됨 (하위 단계 변경)", "Stale (invalidated by downstream changes)"],
WF_State_Failed: ["실패", "Failed"],
WF_State_Complete: ["완료", "Complete"],
@@ -525,7 +525,7 @@ export const ui_locales = {
B02_Proj_Error_Required: ["필수 항목을 입력하세요.", "Please fill in required fields."],
/* --- B03_FileInput 파일 입력 --- */
B03_File_Title: ["파일 입력", "File Input"],
B03_File_Title: ["파일입력", "File Input"],
B03_File_Subtitle: [
"필수 계획노선과 지형·포인트클라우드 파일을 업로드하세요.",
"Upload the required planned route, terrain, and point cloud files.",
@@ -609,7 +609,7 @@ export const ui_locales = {
],
/* --- B04_wf1_Surface 지표면 모델 분석 --- */
B04_Surface_Title: ["1차 · 지표면 모델 분석", "Step 1 · Surface Analysis"],
B04_Surface_Title: ["전처리", "Preprocess"],
B04_Surface_Field_InputId: ["입력 파일 ID", "Input File ID"],
B04_Surface_Field_InputId_Placeholder: ["예: 1", "e.g. 1"],
B04_Surface_Group_Filters: ["지면 필터 선택", "Ground Filters"],
@@ -651,6 +651,11 @@ export const ui_locales = {
B04_Surface_Map_Background: ["배경 지도", "Basemap"],
B04_Surface_Map_GisLayer: ["국가 GIS 레이어", "National GIS Layer"],
B04_Surface_Map_None: ["없음", "None"],
B04_Surface_Map_PlannedRoute: ["계획선", "Planned route"],
B04_Surface_Map_PlannedRouteEmpty: [
"B03에서 계획노선 파일을 올리면 표시됩니다.",
"Shown after the planned route file is uploaded in B03.",
],
B04_Surface_Map_Satellite: ["위성", "Satellite"],
B04_Surface_Map_Hybrid: ["하이브리드", "Hybrid"],
B04_Surface_Map_White: ["백지도", "White map"],
@@ -687,7 +692,7 @@ export const ui_locales = {
B04_Surface_Load_Failed: ["모델 목록을 불러오지 못했습니다.", "Failed to load models."],
/* --- B05_wf2_Route 경로 설계 --- */
B05_Route_Title: ["2차 · 경로 설계", "Step 2 · Route Design"],
B05_Route_Title: ["종단설계", "Profile Design"],
B05_Route_Group_Points: ["경로 제어점", "Route Control Points"],
B05_Route_Point_BP: ["시점 (BP)", "Begin (BP)"],
B05_Route_Point_EP: ["종점 (EP)", "End (EP)"],
@@ -744,7 +749,7 @@ export const ui_locales = {
B05_Route_Field_StationLines: ["측점 가로선", "Station cross lines"],
/* --- B06_wf3_ProfileCross 종·횡단 생성 --- */
B06_Profile_Title: ["3차 · 종·횡단 생성", "Step 3 · Profile & Cross-section"],
B06_Profile_Title: ["횡단설계", "Cross Design"],
B06_Profile_Group_Route: ["대상 경로", "Target Route"],
B06_Profile_Field_RouteId: ["경로 ID (routes.id)", "Route ID"],
B06_Profile_Field_Filter: ["지면 필터", "Ground filter"],
@@ -930,7 +935,7 @@ export const ui_locales = {
],
/* --- B07_wf4_DesignDetail 상세 설계 --- */
B07_Design_Title: ["4차 · 상세 설계", "Step 4 · Detailed Design"],
B07_Design_Title: ["상세설계", "Detail Design"],
B07_Cad_Side_Pending: [
"사이드 패널은 전달 데이터 확정 후 구성됩니다.",
"Side panel will be configured after the upstream data spec is finalized.",
@@ -955,10 +960,10 @@ export const ui_locales = {
B07_Info_Station: ["측점", "Station"],
/* --- B08_wf5_Quantity 수량 산출 --- */
B08_Quantity_Title: ["5차 · 수량 산출", "Step 5 · Quantity Takeoff"],
B08_Quantity_Title: ["수량산출", "Quantity"],
/* --- B09_wf6_Estimation 견적·문서 --- */
B09_Estimation_Title: ["6차 · 견적·문서", "Step 6 · Estimation & Documents"],
B09_Estimation_Title: ["설계도서", "Design Docs"],
/* --- B10_Payment 결재 --- */
B10_Payment_Title: ["결재", "Payment"],
+34
View File
@@ -0,0 +1,34 @@
/* 공통 스플리터(드래그 리사이저) — 패널 경계에 얹는 얇은 손잡이.
레이아웃을 밀지 않도록 경계 위에 절대배치하고, 평소에는 보이지 않다가
마우스를 얹거나 끌 때만 색이 들어온다. */
.ui-resizer {
position: absolute;
z-index: 1;
background: transparent;
transition: background var(--transition-fast);
touch-action: none;
}
.ui-resizer:hover,
.ui-resizer.is-dragging {
background: var(--color-primary);
}
/* 위아래로 끌어 높이를 조절 — 패널 위쪽 경계에 가로로 눕는다. */
.ui-resizer--vertical {
top: -3px;
right: 0;
left: 0;
height: 6px;
cursor: ns-resize;
}
/* 좌우로 끌어 폭을 조절 — 패널 왼쪽 경계에 세로로 선다. */
.ui-resizer--horizontal {
top: 0;
bottom: 0;
left: -3px;
width: 6px;
cursor: ew-resize;
}
+121
View File
@@ -0,0 +1,121 @@
import "./ui_template_resizer.css";
/* =============================================================================
* ui_template_resizer.ts
* 공통 스플리터 — 패널 경계를 끌어 크기를 조절한다.
*
* 조절값은 `sessionStorage`에만 남긴다. 같은 브라우저 세션에서는 페이지를 오가도
* 유지되고, 브라우저를 다시 열면 기본값으로 돌아간다(2026-08-01 사용자 지시).
* 사용자별 영구 저장은 나중에 따로 검토한다.
* ========================================================================== */
export interface PanelResizerOptions {
/** vertical=위아래로 끌어 높이 조절, horizontal=좌우로 끌어 폭 조절 */
axis: "vertical" | "horizontal";
/** 크기를 적용할 패널 */
target: HTMLElement;
/**
* 크기를 담을 CSS 변수 이름(예: `--b05-profile-height`).
* 인라인 width/height로 박으면 `.is-collapsed { height: 0 }` 같은 접기 규칙을 이겨 버려
* 패널이 접히지 않는다. 변수로 넘기면 접기 규칙이 그대로 이긴다.
*/
cssVar: string;
/** 손잡이가 패널의 위/왼쪽에 있으면 -1 — 끌어올리거나 왼쪽으로 끌면 커진다. */
direction: 1 | -1;
/** 최소 크기(px) */
min: number;
/** 최대 크기(px). 함수면 끌 때마다 다시 계산한다(부모의 70% 같은 상대 상한). */
max: number | (() => number);
/** 세션 저장 키. 생략하면 저장하지 않는다. */
storageKey?: string;
/** 크기가 바뀔 때마다 호출한다(차트 재계산 등). */
onResize?: (size: number) => void;
}
export interface PanelResizer {
/** 패널 경계에 붙일 손잡이. 패널이 `position: relative`여야 제자리에 앉는다. */
root: HTMLElement;
/** 저장된 크기를 다시 적용한다(재렌더 후 복원용). */
restore: () => void;
/** 기본 크기로 되돌린다(저장값도 지운다). */
reset: () => void;
dispose: () => void;
}
export function createPanelResizer(options: PanelResizerOptions): PanelResizer {
const { axis, target, cssVar, direction, min, storageKey, onResize } = options;
const root = document.createElement("div");
root.className = `ui-resizer ui-resizer--${axis}`;
root.setAttribute("role", "separator");
root.setAttribute("aria-orientation", axis === "vertical" ? "horizontal" : "vertical");
const maxOf = (): number => (typeof options.max === "function" ? options.max() : options.max);
const clamp = (size: number): number => {
const max = Math.max(min, maxOf());
return Math.min(max, Math.max(min, size));
};
function apply(size: number, persist: boolean): void {
const next = clamp(size);
target.style.setProperty(cssVar, `${Math.round(next)}px`);
if (persist && storageKey) sessionStorage.setItem(storageKey, String(Math.round(next)));
onResize?.(next);
}
function restore(): void {
if (!storageKey) return;
const saved = Number(sessionStorage.getItem(storageKey));
// 저장한 뒤 창 크기가 바뀌었을 수 있으니 복원할 때도 상·하한을 다시 씌운다.
if (Number.isFinite(saved) && saved > 0) apply(saved, false);
}
function reset(): void {
target.style.removeProperty(cssVar);
if (storageKey) sessionStorage.removeItem(storageKey);
onResize?.(axis === "vertical" ? target.clientHeight : target.clientWidth);
}
let startPosition = 0;
let startSize = 0;
root.addEventListener("pointerdown", (event) => {
// 왼쪽 버튼만 — 가운데·오른쪽 버튼은 브라우저 기본 동작에 맡긴다.
if (event.button !== 0) return;
event.preventDefault();
startPosition = axis === "vertical" ? event.clientY : event.clientX;
startSize = axis === "vertical" ? target.clientHeight : target.clientWidth;
root.classList.add("is-dragging");
// 끄는 동안 크기 transition이 걸려 있으면 손이 끄는 만큼 늦게 따라온다.
target.classList.add("is-resizing");
root.setPointerCapture(event.pointerId);
});
root.addEventListener("pointermove", (event) => {
if (!root.hasPointerCapture(event.pointerId)) return;
const current = axis === "vertical" ? event.clientY : event.clientX;
apply(startSize + (current - startPosition) * direction, true);
});
const stop = (event: PointerEvent): void => {
if (!root.hasPointerCapture(event.pointerId)) return;
root.releasePointerCapture(event.pointerId);
root.classList.remove("is-dragging");
target.classList.remove("is-resizing");
};
root.addEventListener("pointerup", stop);
root.addEventListener("pointercancel", stop);
// 더블클릭하면 기본 크기로 되돌린다(잘못 끌었을 때 되돌리는 가장 빠른 길).
root.addEventListener("dblclick", reset);
restore();
return {
root,
restore,
reset,
dispose() {
root.remove();
},
};
}