From 9b6547a4186ae1c403bc51b41d4ea8f2e451de5c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 14:05:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(B04/B05/=EA=B3=B5=ED=86=B5):=207=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EB=AA=85=EC=B9=AD=20=ED=86=B5=EC=9D=BC,=20B05=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=EB=A6=AC?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=A6=88,=20B04=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EC=84=A0=20=EB=A0=88=EC=9D=B4=EC=96=B4,=202D=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=EC=A2=8C=EB=93=9C=EB=9E=98=EA=B7=B8=20=ED=8C=AC=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 진행단계 오버레이 버튼과 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) --- B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts | 13 ++ B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py | 40 ++++++ .../B04_wf1_Surface_UI_MapRender.ts | 8 ++ .../B04_wf1_Surface_UI_MapViewer.ts | 57 ++++++++- B04_wf1_Surface/B04_wf1_Surface_UI_Style.css | 26 +++- .../B05_wf2_Route_UI_Drainage_Panel.ts | 47 ++++++- B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 7 +- .../B05_wf2_Route_UI_Profile_Panel.ts | 34 ++++- B05_wf2_Route/B05_wf2_Route_UI_Style.css | 30 +++-- ui_template/ui_template_locale.ts | 31 +++-- ui_template/ui_template_resizer.css | 34 +++++ ui_template/ui_template_resizer.ts | 121 ++++++++++++++++++ 12 files changed, 411 insertions(+), 37 deletions(-) create mode 100644 ui_template/ui_template_resizer.css create mode 100644 ui_template/ui_template_resizer.ts diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index 52037edb..4ccab99d 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -277,6 +277,19 @@ export async function fetchGisGeoJson(projectId: string, layer: string): Promise }); } +/** 계획노선(B03 업로드 CSV)의 평면 점 목록. 사업지 좌표계(m) — 배경 지도 메타와 같은 좌표계다. */ +export interface PlannedRouteResponse { + status: string; + points: Array<{ x: number; y: number }>; +} + +/** 2D 지도에 계획선을 겹쳐 그리기 위한 점 목록을 받는다. 없으면 빈 목록이 온다. */ +export async function fetchPlannedRoute(projectId: string): Promise { + return requestJson(`/projects/${projectId}/planned-route`, { + method: "GET", + }); +} + /* ── 배수유역 분석 (B04_wf1_Surface_Router_Watershed.py) ──────────────────── * 관리자 확인용. 계획 노선(B03 CSV) + 도엽 등고선·세류선으로 유역을 끝까지 분석하고 * 결과를 영구저장소에 남긴다. 30초 안팎이 걸리므로 여기서 한 번만 돌린다. diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py b/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py index 58b70861..cbf98838 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py @@ -237,3 +237,43 @@ async def get_vector_tile(project_id: UUID, layer: str, z: int, x: int, y: int) empty_tile = mapbox_vector_tile.encode([]) if mapbox_vector_tile else b"" return Response(content=empty_tile, media_type="application/x-protobuf") + + +# 계획노선(B03 업로드 CSV) 폴리라인 조회 — 2D 지도에 계획선을 겹쳐 그리는 데 쓴다. +@router.get("/{project_id}/planned-route", response_model=None) +async def get_planned_route(project_id: UUID) -> dict[str, Any] | JSONResponse: + """B03에 업로드된 계획노선을 사업지 좌표계(m) 점 목록으로 돌려준다. + + B05의 확정 경로가 아니라 **원청이 준 계획선**이다. 아직 올리지 않았거나 좌표가 + 모자라면 빈 목록을 돌려준다 — 화면은 계획선만 빼고 그대로 그린다. + """ + from B04_wf1_Surface.B04_wf1_Surface_Engine_Extent import project_epsg_from_prj + from common_util.common_util_route_geometry import ( + find_planned_route_file, + read_planned_route_csv, + ) + + pool = get_db_pool() + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + project_root = Path(resolve_stored_project_path(stored_path)) + route_file = find_planned_route_file(project_root / "B03_FileInput" / "input") + planned = read_planned_route_csv(route_file) if route_file else None + if planned is None or len(planned.vertices) < 2: + return {"status": "success", "points": []} + + # 배경 지도 메타와 같은 좌표계로 맞춘다. 노선 파일이 제 좌표계를 적어 두었고 + # 그것이 프로젝트 좌표계와 다르면 여기서 한 번 옮긴다. + target_epsg = project_epsg_from_prj(project_root) + points = [(float(vertex.x), float(vertex.y)) for vertex in planned.vertices] + source_epsg = f"EPSG:{planned.epsg}" if planned.epsg else target_epsg + if source_epsg != target_epsg: + from pyproj import Transformer + + transformer = Transformer.from_crs(source_epsg, target_epsg, always_xy=True) + points = [transformer.transform(x, y) for x, y in points] + return {"status": "success", "points": [{"x": x, "y": y} for x, y in points]} + except Exception as exc: + logger.warning("계획노선 조회 실패: %s", exc) + return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)}) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts index 05b5afc1..0baf2398 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -24,6 +24,14 @@ export type MarkerKind = "dot" | "x"; /** 상류 세류망 강조 색 — 유역 판정의 기준선이라 가장 굵고 진하게 둔다. */ const UPSTREAM_LINE_COLOR = "rgba(29, 78, 216, 0.95)"; +/** + * 계획선(노선) 표기 색 — 주황. B04 2D 지도와 B05 배수유역도가 **같은 값**을 쓴다. + * 두 화면에서 같은 선을 다른 색으로 그리면 같은 것인지 알아볼 수 없다. + */ +export const ROUTE_LINE_COLOR = "#f97316"; +/** 계획선 굵기(px) — 다른 레이어보다 굵게 둬야 배경 위에서 바로 눈에 띈다. */ +export const ROUTE_LINE_WIDTH = 2.4; + /** * 사전 투영된 하나의 파트(선/링/점 묶음). 좌표는 정규화 맵 좌표(0~1) x,y 교차 배열. * weights: Douglas-Peucker 가중치(정점 제거 시 발생하는 최대 오차, 종횡비 보정 좌표계). diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 791ee668..2b5c2892 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -3,6 +3,7 @@ import { createProgressCircle } from "@ui/ui_template_progress"; import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { fetchGisGeoJson, + fetchPlannedRoute, fetchVWorldMeta, getVWorldMapUrl, type VWorldMeta, @@ -16,6 +17,9 @@ import { drawPreparedLabels, drawPreparedLayer, prepareLayer, + prepareMetricPolyline, + ROUTE_LINE_COLOR, + ROUTE_LINE_WIDTH, type GeoJsonCollection, type MapRect, type Normalizer, @@ -143,11 +147,14 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { empty.textContent = L("B04_Surface_Map_Empty"); const status = document.createElement("span"); status.className = "b04-map__status"; - // 지도 위 좌상단 문구 묶음 — 지도 상태와 배수유역 상태를 세로로 쌓는다. - // 배경지도가 복잡해 글자가 묻히므로 각 문구에 배경 칩을 깐다(2026-08-01 사용자 지시). + // 지도 위 좌상단 묶음 — 배수유역 정보 자리다(2026-08-01 사용자 지시). + // 배경지도가 복잡해 글자가 묻히므로 각 문구에 배경 칩을 깐다. const statusStack = document.createElement("div"); statusStack.className = "b04-map__status-stack"; - statusStack.append(status); + // 객체 표시(지형지물 개수) 라벨은 우측 최하단으로 내린다 — 좌상단을 배수유역에 내주기 위함. + const statusCorner = document.createElement("div"); + statusCorner.className = "b04-map__status-corner"; + statusCorner.append(status); const scaleBar = document.createElement("div"); scaleBar.className = "b04-map__scale"; const scaleText = document.createElement("span"); @@ -160,6 +167,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { canvas, empty, statusStack, + statusCorner, scaleBar, progress.root, ); @@ -184,6 +192,9 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { ); const activeGisLayers = new Set(GIS_LAYERS.filter((layer) => GIS_DEFAULT_ON[layer])); let showContourLabels = CONTOUR_LABEL_DEFAULT_ON; + // 계획선(B03 업로드 계획노선) — 사업지 좌표계(m) 폴리라인을 배경 지도 위에 겹친다. + let routeLayer: PreparedLayer | null = null; + let showRoute = true; let scale = 1; let offsetX = 0; let offsetY = 0; @@ -244,6 +255,22 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { 도엽_성절토: L("B04_Surface_Map_SheetCutFill"), 도엽_옹벽석축: L("B04_Surface_Map_SheetWall"), }; + // 계획선 토글 — 도엽 레이어와 같은 양식으로 레이어 줄 맨 앞에 둔다. 색은 B05 배수유역도와 + // 같은 주황(정의처: MapRender). 계획노선을 아직 올리지 않았으면 버튼이 눌리지 않는다. + const routeButton = document.createElement("button"); + routeButton.type = "button"; + routeButton.className = "b04-map__layer-button b04-map__layer-button--gis is-active"; + routeButton.textContent = L("B04_Surface_Map_PlannedRoute"); + routeButton.style.setProperty("--b04-layer-color", ROUTE_LINE_COLOR); + routeButton.setAttribute("aria-pressed", "true"); + routeButton.addEventListener("click", () => { + showRoute = !showRoute; + routeButton.classList.toggle("is-active", showRoute); + routeButton.setAttribute("aria-pressed", String(showRoute)); + scheduleDraw(); + }); + gisButtons.append(routeButton); + GIS_LAYERS.forEach((layer) => { gisButtons.append( makeLayerButton(gisLabels[layer], activeGisLayers, layer, GIS_LAYER_COLORS[layer]), @@ -380,6 +407,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { } // 배수유역 오버레이는 GIS 레이어 위에 얹는다 — 격자·화살표가 등고선을 덮어야 읽힌다. if (normalizer) watershed.draw(context, normalizer, view); + // 계획선은 맨 위에 둔다 — 다른 레이어에 덮이면 노선이 어디로 지나는지 읽을 수 없다. + if (showRoute && routeLayer) { + context.lineWidth = ROUTE_LINE_WIDTH; + context.strokeStyle = ROUTE_LINE_COLOR; + drawPreparedLayer(context, routeLayer, view, "dot"); + } updateImageTransform(); drawScaleBar(mapRect); } @@ -400,11 +433,16 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { backgroundImages.forEach((image) => image.removeAttribute("src")); meta = null; preparedLayers.clear(); + routeLayer = null; resetView(); status.textContent = L("B04_Surface_Map_Loading"); showProgress(0, L("B04_Surface_Map_Loading")); try { - const nextMeta = await fetchVWorldMeta(projectId, "satellite"); + // 계획선은 지도 메타와 같은 좌표계라 함께 받아 둔다. 실패해도 지도는 그대로 그린다. + const [nextMeta, planned] = await Promise.all([ + fetchVWorldMeta(projectId, "satellite"), + fetchPlannedRoute(projectId).catch(() => ({ status: "error", points: [] })), + ]); // 레이어가 끝나는 대로 진행률을 올린다 — 10종을 다 받을 때까지 화면이 비어 있어서다. let done = 0; const loadedLayers = await Promise.all( @@ -431,6 +469,11 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { meta = nextMeta; // 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다. normalizer = createNormalizer(nextMeta); + routeLayer = + planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null; + // 계획노선을 아직 올리지 않은 프로젝트에서는 켤 것이 없으니 버튼을 잠근다. + routeButton.disabled = routeLayer === null; + routeButton.title = routeLayer === null ? L("B04_Surface_Map_PlannedRouteEmpty") : ""; let featureCount = 0; loadedLayers.forEach(([layer, data]) => { if (!data) return; @@ -479,7 +522,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { // 중간 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹쳐 페이지 전체를 // 흔들므로 기본 동작을 차단하고 지도 팬으로만 사용한다. if (event.button === 1) event.preventDefault(); + // 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 화면 위 객체를 고르는 데만 쓴다 + // (좌버튼 드래그가 팬까지 겸하면 객체를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시). + // 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다. + if (event.pointerType === "mouse" && event.button !== 1) return; dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY }; + viewport.style.cursor = "grabbing"; viewport.setPointerCapture(event.pointerId); }); viewport.addEventListener("pointermove", (event) => { @@ -490,6 +538,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { }); const stopDragging = (): void => { dragStart = null; + viewport.style.removeProperty("cursor"); }; viewport.addEventListener("pointerup", stopDragging); viewport.addEventListener("pointercancel", stopDragging); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css index d8036e11..1d82ceb5 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css @@ -574,16 +574,15 @@ height: 560px; overflow: hidden; touch-action: none; - cursor: grab; + /* 좌버튼은 선택 전용이라 손바닥(grab) 커서를 쓰지 않는다. 팬 중(가운데 버튼)에만 + JS가 grabbing으로 바꾼다(2026-08-01 사용자 지시). */ + cursor: default; + user-select: none; border: 1px solid var(--color-border); border-radius: var(--radius-cards); background: var(--color-canvas); } -.b04-map__viewport:active { - cursor: grabbing; -} - .b04-map__image, .b04-map__canvas { position: absolute; @@ -612,7 +611,7 @@ font-size: var(--text-caption); } -/* 지도 위 좌상단 문구 묶음 — 지도 상태와 배수유역 상태를 세로로 쌓는다. +/* 지도 위 좌상단 문구 묶음 — 배수유역 정보 자리(2026-08-01 사용자 지시). 지도 조작을 가리지 않도록 포인터 이벤트는 통과시킨다. */ .b04-map__status-stack { position: absolute; @@ -627,6 +626,21 @@ pointer-events: none; } +/* 객체 표시(지형지물 개수) 라벨 자리 — 우측 최하단(2026-08-01 사용자 지시). + 축척은 좌하단이라 서로 겹치지 않는다. */ +.b04-map__status-corner { + position: absolute; + z-index: 2; + right: var(--spacing-12); + bottom: var(--spacing-12); + display: flex; + max-width: min(50%, 480px); + flex-direction: column; + align-items: flex-end; + gap: var(--spacing-4); + pointer-events: none; +} + /* 배수유역 전용 상태 줄 — 지도 자체 상태(.b04-map__status)와 칸을 나눠 쓰면 나중에 끝난 쪽이 상대 문구를 지운다. 그래서 줄을 따로 둔다. 배경지도(위성·지적·등고선)가 복잡해 글자가 묻히므로 배경 칩을 깐다. */ diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 15006d2c..3cfc61c2 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -1,4 +1,5 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { createPanelResizer } from "@ui/ui_template_resizer"; import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { fetchVWorldMeta, @@ -15,6 +16,8 @@ import { drawUpstreamLines, prepareLayer, prepareMetricPolyline, + ROUTE_LINE_COLOR, + ROUTE_LINE_WIDTH, type GeoJsonCollection, type MapRect, type Normalizer, @@ -57,9 +60,18 @@ const LAYER_LABELS: Record = { /** 도엽 레이어가 아닌 표시 토글의 띠 색 — 지도에 그려지는 선 색과 맞춘다. */ const ARROW_TOGGLE_COLOR = "#7c3aed"; const UPSTREAM_TOGGLE_COLOR = "#1d4ed8"; +/** 위성사진은 선이 아니라 배경이라 맞출 선 색이 없다 — 중립 회색을 띠 색으로 쓴다. */ +const SATELLITE_TOGGLE_COLOR = "#64748b"; -const ROUTE_COLOR = "#f97316"; +/** 계획선 색은 B04 2D 지도와 같은 값을 쓴다(정의처: MapRender). */ +const ROUTE_COLOR = ROUTE_LINE_COLOR; const COLLAPSED_KEY = "b05-route-drainage-collapsed"; +/** 드래그로 조절한 패널 폭(px) 보관 키 — 브라우저 세션 동안만 유지한다. */ +const WIDTH_KEY = "b05-route-drainage-width"; +/** 지도가 담기는 최소 폭(px). CSS의 min-width와 같은 값. */ +const MIN_PANEL_WIDTH = 320; +/** 상한은 하단 패널 폭의 70%까지(사용자 지시) — 종단면도가 최소한 30%는 남아야 한다. */ +const MAX_PANEL_WIDTH_RATIO = 0.7; /** 유역 오버레이 파스텔 색상. 번호 순으로 돌려쓴다(사용자 지시: 파스텔톤). */ const BASIN_COLORS = [ @@ -163,7 +175,18 @@ export function createDrainagePanel(): DrainagePanel { const basinList = document.createElement("div"); basinList.className = "b05-drainage__basins"; basinList.hidden = true; - root.append(panelHandle.root, header, viewport, basinList); + // 왼쪽 경계를 끌어 폭을 조절한다. 상한은 하단 패널 폭의 70%, + // 세로는 하단 패널에 그대로 딸려 간다(따로 조절하지 않는다 — 사용자 지시). + const widthResizer = createPanelResizer({ + axis: "horizontal", + target: root, + cssVar: "--b05-drainage-width", + direction: -1, + min: MIN_PANEL_WIDTH, + max: () => (root.parentElement?.clientWidth ?? window.innerWidth) * MAX_PANEL_WIDTH_RATIO, + storageKey: WIDTH_KEY, + }); + root.append(panelHandle.root, widthResizer.root, header, viewport, basinList); let projectId: string | null = null; let meta: VWorldMeta | null = null; @@ -229,6 +252,17 @@ export function createDrainagePanel(): DrainagePanel { return button; } + // 배경 위성사진 — 등고선 앞에 둔다(2026-08-01 사용자 지시). 사진이 어두워 유역 채움색이 + // 묻힐 때 끄고 본다. 캔버스가 아니라 배경 이미지라 표시 여부만 직접 바꾼다. + addLayerToggle( + "위성사진", + SATELLITE_TOGGLE_COLOR, + true, + (next) => { + backgroundImage.hidden = !next; + }, + "배경 위성사진을 보이거나 숨깁니다.", + ); DRAINAGE_LAYERS.forEach((layer) => { addLayerToggle(LAYER_LABELS[layer], LAYER_COLORS[layer], true, (next) => { if (next) activeLayers.add(layer); @@ -314,7 +348,7 @@ export function createDrainagePanel(): DrainagePanel { drawUpstreamLines(context, upstreamLines, normalizer, view); } if (routeLayer) { - context.lineWidth = 2.4; + context.lineWidth = ROUTE_LINE_WIDTH; context.strokeStyle = ROUTE_COLOR; drawPreparedLayer(context, routeLayer, view, "dot"); } @@ -606,7 +640,12 @@ export function createDrainagePanel(): DrainagePanel { viewport.setPointerCapture(event.pointerId); return; } + // 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 유역선 핸들·배관 마커를 고르고 끄는 데만 쓴다 + // (좌버튼이 팬까지 겸하면 마커를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시). + // 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다. + if (event.pointerType === "mouse" && event.button !== 1) return; dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY }; + viewport.style.cursor = "grabbing"; viewport.setPointerCapture(event.pointerId); }); viewport.addEventListener("pointermove", (event) => { @@ -634,6 +673,7 @@ export function createDrainagePanel(): DrainagePanel { boundaryEditor.handleUp(); pipeEditor.handleUp(); dragStart = null; + viewport.style.removeProperty("cursor"); }; viewport.addEventListener("pointerup", stopDragging); viewport.addEventListener("pointercancel", stopDragging); @@ -676,6 +716,7 @@ export function createDrainagePanel(): DrainagePanel { frameHandle = 0; } resizeObserver.disconnect(); + widthResizer.dispose(); }, }; } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 54d429a0..e1fec28a 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -1,4 +1,5 @@ import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; import { purgeOtherProjects } from "../A00_Common/b_asset_cache"; import { createProgressCircle } from "@ui/ui_template_progress"; @@ -50,6 +51,10 @@ import "./B05_wf2_Route_UI_Style.css"; type GradeClass = RoutePanelValues["gradeClass"]; type RoadWidths = Record; +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + const DEFAULT_ROAD_WIDTHS: RoadWidths = { trunk: 3, branch: 3, work: 2.5 }; async function fetchRoadWidths(projectId: string): Promise { @@ -628,7 +633,7 @@ export async function renderB05Route(root: HTMLElement): Promise { mainContent.className = "b05-route__main"; mainContent.append(viewer.root, profilePanel.root); const layout = createWorkflowLayout({ - title: "종단 설계", + title: L("B05_Route_Title"), steps: workflowSteps(), activeStep: 2, leftPanel: panel.root, diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 9b9cece8..243f0602 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -21,6 +21,7 @@ import { } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal"; import { LONG_PAD } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { createPanelResizer } from "@ui/ui_template_resizer"; import { createDrainagePanel } from "./B05_wf2_Route_UI_Drainage_Panel"; import { createProgressCircle } from "@ui/ui_template_progress"; import { showToast } from "@ui/ui_template_elements"; @@ -57,9 +58,18 @@ import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css"; import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css"; const COLLAPSED_KEY = "b05-route-profile-collapsed"; +/** 드래그로 조절한 하단 패널 높이(px) 보관 키 — 브라우저 세션 동안만 유지한다. */ +const HEIGHT_KEY = "b05-route-profile-height"; +/** 처음 잡힌 도면 테이블 높이(px). 패널 높이와 같은 수명(세션)으로 함께 남긴다 — + * 페이지를 다시 들어와도 테이블 높이가 달라지지 않아야 한다. */ +const TABLE_HEIGHT_KEY = "b05-route-profile-table-height"; /** 정보 라인을 뺀 본문 세로를 그래프 40% : 테이블 60%로 나눈다(4:6, 6이 테이블). */ const CHART_HEIGHT_RATIO = 0.4; const MIN_CHART_HEIGHT = 100; +/** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */ +const MIN_PANEL_HEIGHT = 180; +/** 상한은 3D 뷰포트가 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */ +const MAX_PANEL_HEIGHT_RATIO = 0.9; /** 테이블 12행. 행 높이와 셀 폭에서 글자 크기를 정하는 데 쓴다. */ const TABLE_ROW_COUNT = 12; @@ -284,7 +294,18 @@ export function createRouteProfilePanel( content.className = "b05-route-profile__content"; const drainagePanel = createDrainagePanel(); content.append(bodyWrap, drainagePanel.root); - root.append(panelHandle.root, balanceBar, content); + // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은 + // 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다. + const heightResizer = createPanelResizer({ + axis: "vertical", + target: root, + cssVar: "--b05-profile-height", + direction: -1, + min: MIN_PANEL_HEIGHT, + max: () => (root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO, + storageKey: HEIGHT_KEY, + }); + root.append(panelHandle.root, heightResizer.root, balanceBar, content); drainagePanel.load(projectId); let detail: SectionDetailResponse | null = null; @@ -301,6 +322,8 @@ export function createRouteProfilePanel( let redrawPending = false; let lastWidth = 0; let lastHeight = 0; + /** 처음 그릴 때 잡힌 도면 테이블 높이(px). 패널을 끌어도 이 값을 지킨다. */ + let fixedTableHeight = Number(sessionStorage.getItem(TABLE_HEIGHT_KEY)) || 0; function renderBalance(): void { balanceBar.replaceChildren(); @@ -430,8 +453,14 @@ export function createRouteProfilePanel( // 그래프 40% : 테이블 60% (상단 정보 라인은 본문 밖이라 애초에 빠져 있다). // 가로 스크롤바를 `overflow-x: scroll`로 항상 띄우므로 clientHeight에서 이미 빠져 있다. const available = Math.max(120, body.clientHeight); + // 첫 화면에서만 4:6으로 나누고, 그때 잡힌 테이블 높이를 이후로도 그대로 쓴다. + // 패널을 끌어 늘리거나 줄이면 그 차이는 전부 그래프가 흡수한다(사용자 지시). + if (alignment && fixedTableHeight <= 0) { + fixedTableHeight = Math.round(available * (1 - CHART_HEIGHT_RATIO)); + sessionStorage.setItem(TABLE_HEIGHT_KEY, String(fixedTableHeight)); + } const chartHeight = alignment - ? Math.max(MIN_CHART_HEIGHT, Math.round(available * CHART_HEIGHT_RATIO)) + ? Math.max(MIN_CHART_HEIGHT, available - fixedTableHeight) : available; const tableHeight = available - chartHeight; @@ -655,6 +684,7 @@ export function createRouteProfilePanel( dispose() { window.clearTimeout(resizeTimer); resizeObserver.disconnect(); + heightResizer.dispose(); }, }; } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index d09c8761..4053c861 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -47,7 +47,8 @@ left: 0; display: flex; height: 60vh; - height: 60dvh; + /* 기본은 화면의 60%. 위쪽 경계를 끌면 --b05-profile-height(px)가 대신 들어온다. */ + height: var(--b05-profile-height, 60dvh); flex-direction: column; min-height: 0; overflow: visible; @@ -56,6 +57,18 @@ transition: height var(--transition-fast); } +/* 끄는 동안에는 transition을 끈다 — 켜 두면 손보다 패널이 늦게 따라온다. */ +.b05-route-profile.is-resizing, +.b05-drainage.is-resizing { + transition: none; +} + +/* 접힌 패널에는 끌 것이 없다 — 손잡이를 숨겨 잘못 잡히지 않게 한다. */ +.b05-route-profile.is-collapsed > .ui-resizer, +.b05-drainage.is-collapsed > .ui-resizer { + display: none; +} + .b05-route-profile.is-collapsed { height: 0; min-height: 0; @@ -835,9 +848,11 @@ .b05-drainage { position: relative; display: flex; - width: 38%; + /* 기본은 하단 패널의 38%. 왼쪽 경계를 끌면 --b05-drainage-width(px)가 대신 들어온다. + 상한 70%는 종단면도가 최소 30%는 남게 하는 안전판(JS 쪽 클램프와 같은 값). */ + width: var(--b05-drainage-width, 38%); min-width: 320px; - max-width: 640px; + max-width: 70%; flex: 0 0 auto; flex-direction: column; min-height: 0; @@ -915,14 +930,13 @@ min-height: 0; overflow: hidden; background: var(--color-surface); - cursor: grab; + /* 좌버튼은 유역선·배관 마커를 고르는 데만 쓴다. 팬 중(가운데 버튼)에만 JS가 + grabbing으로 바꾼다(2026-08-01 사용자 지시). */ + cursor: default; + user-select: none; touch-action: none; } -.b05-drainage__viewport:active { - cursor: grabbing; -} - .b05-drainage__image, .b05-drainage__canvas { position: absolute; diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 253e8b66..4b7f7e7e 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -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"], diff --git a/ui_template/ui_template_resizer.css b/ui_template/ui_template_resizer.css new file mode 100644 index 00000000..90453421 --- /dev/null +++ b/ui_template/ui_template_resizer.css @@ -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; +} diff --git a/ui_template/ui_template_resizer.ts b/ui_template/ui_template_resizer.ts new file mode 100644 index 00000000..e216ff5d --- /dev/null +++ b/ui_template/ui_template_resizer.ts @@ -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(); + }, + }; +}