From 85eda04a8e73b6ba8cbfd4edceb7d6e0f95bce6d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 13:08:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(B04/B05):=20=EC=A7=80=EB=8F=84=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EB=8F=84=EB=A1=9C=20?= =?UTF-8?q?=EC=A4=91=EC=8B=AC=20+=20=EC=97=AC=EC=9C=A0=20200m=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B04 하단 지도와 B05 배수유역도가 같은 규칙으로 열린다 — 계획도로 중심을 화면 중앙에 두고 도로 전체 + 사방 200m가 보이는 배율(2026-08-01 사용자 지시). - computeRouteView()를 공용 렌더 엔진(B04_wf1_Surface_UI_MapRender)에 두어 두 화면이 같은 정의를 쓴다. 여유 거리는 ROUTE_VIEW_MARGIN_M 한 곳에서 정의. - B04는 도로 범위를 알 방법이 없어 GET /surface/confirmed 응답에 route_bounds를 추가 (B03 계획노선 CSV 범위를 프로젝트 좌표계로 변환). 노선이 없으면 배경 전체 보기로 폴백. - B05는 기존 상대 배율(0.85배) 대신 같은 함수를 쓴다. - 표본 실측: 노선 239x166m -> 여유 포함 639x566m가 화면에 들어온다. Co-Authored-By: Claude Opus 5 (1M context) --- B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts | 2 + .../B04_wf1_Surface_Engine_Extent.py | 32 ++++++++++++++ B04_wf1_Surface/B04_wf1_Surface_Router.py | 9 ++++ B04_wf1_Surface/B04_wf1_Surface_Schema.py | 2 + .../B04_wf1_Surface_UI_MapRender.ts | 44 +++++++++++++++++++ .../B04_wf1_Surface_UI_MapViewer.ts | 39 ++++++++++------ B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts | 5 ++- .../B05_wf2_Route_UI_Drainage_Panel.ts | 24 +++++----- 8 files changed, 128 insertions(+), 29 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index 4b23b8f4..52037edb 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -132,6 +132,8 @@ export interface SurfaceConfirmedResponse { z_min: number; z_max: number; } | null; + /** 계획노선(B03 CSV)의 평면 범위. 지도 초기 화면을 도로 중심으로 맞출 때 쓴다. */ + route_bounds: { x_min: number; x_max: number; y_min: number; y_max: number } | null; } /** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. diff --git a/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py b/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py index ca95774f..e84fc474 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Engine_Extent.py @@ -120,6 +120,38 @@ def satellite_extent( } +def planned_route_bounds(project_root: Path, target_epsg: str) -> dict[str, float] | None: + """계획노선(B03 CSV)의 평면 범위를 프로젝트 좌표계로 돌려준다. 없으면 None. + + 지도(2D) 초기 화면을 도로 기준으로 맞출 때 쓴다 — 화면 쪽은 도로 범위만 알면 된다. + """ + route = read_planned_route(project_root) + if not route: + return None + bounds = route["bounds"] + corners = [ + (float(bounds["x_min"]), float(bounds["y_min"])), + (float(bounds["x_max"]), float(bounds["y_max"])), + ] + try: + moved = _to_target_crs(corners, route.get("epsg"), target_epsg) + except Exception as exc: + logger.warning("B04 계획노선 범위 변환 실패 (%s)", exc) + return None + xs = [point[0] for point in moved] + ys = [point[1] for point in moved] + return {"x_min": min(xs), "x_max": max(xs), "y_min": min(ys), "y_max": max(ys)} + + +def project_epsg_from_prj(project_root: Path) -> str: + """프로젝트 PRJ에서 좌표계를 읽는다. 없으면 중부원점(EPSG:5186).""" + from .B04_wf1_Surface_Engine_VWorld import get_epsg_from_prj + + for prj_path in sorted(project_root.glob("B03_FileInput/**/*.prj")): + return get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore")) + return "EPSG:5186" + + def map_meta_covers(meta_path: Path, extent: dict[str, list[float]]) -> bool: """저장된 배경 지도가 필요한 범위를 이미 덮고 있는가. diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router.py b/B04_wf1_Surface/B04_wf1_Surface_Router.py index 93568f4f..c9cfe5a2 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router.py @@ -18,6 +18,10 @@ from B04_wf1_Surface.B04_wf1_Surface_Engine import ( cache_ground_points, run_surface_analysis, ) +from B04_wf1_Surface.B04_wf1_Surface_Engine_Extent import ( + planned_route_bounds, + project_epsg_from_prj, +) from B04_wf1_Surface.B04_wf1_Surface_Repository import ( clear_confirmed_surface_models, get_input_file, @@ -415,6 +419,10 @@ async def get_confirmed_surface(project_id: UUID) -> SurfaceConfirmedResponse | "z_max": float(bounds[2, 1]), } + # 지도(2D) 초기 화면을 도로 기준으로 맞추기 위한 계획노선 범위(없으면 None). + project_root = processed_dir.parent.parent + route_bounds = planned_route_bounds(project_root, project_epsg_from_prj(project_root)) + signature = "|".join( str(value) for value in ( @@ -435,6 +443,7 @@ async def get_confirmed_surface(project_id: UUID) -> SurfaceConfirmedResponse | signature=signature, point_count=point_count, bounds=bounds_payload, + route_bounds=route_bounds, ) except LookupError as exc: return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) diff --git a/B04_wf1_Surface/B04_wf1_Surface_Schema.py b/B04_wf1_Surface/B04_wf1_Surface_Schema.py index 3e0ec724..cf6520ad 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Schema.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Schema.py @@ -127,6 +127,8 @@ class SurfaceConfirmedResponse(BaseModel): signature: str point_count: int | None = None bounds: dict[str, float] | None = None + # 계획노선(B03 CSV)의 평면 범위. 지도 초기 화면을 도로 기준으로 맞출 때 쓴다. + route_bounds: dict[str, float] | None = None class SurfaceGroundStatsResponse(BaseModel): diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts index 077f95de..05b5afc1 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -105,6 +105,50 @@ export function computeMapRect(meta: VWorldMeta | null, width: number, height: n }; } +/** 계획도로 주변으로 보여줄 여유 거리(m). B04 하단 지도와 B05 배수유역도가 같은 값을 쓴다 + * (2026-08-01 사용자 지시: 도로 중심을 화면 중앙에, 도로 전체 + 200m까지). */ +export const ROUTE_VIEW_MARGIN_M = 200; + +/** 평면 좌표(m) 범위. */ +export interface PlanBounds { + x_min: number; + x_max: number; + y_min: number; + y_max: number; +} + +/** + * 도로 전체 + 여유 거리가 화면에 들어오도록 배율·이동량을 구한다(도로 중심이 화면 중앙). + * + * 지도 초기 화면의 유일한 정의처 — B04 하단 지도와 B05 배수유역도가 함께 쓴다. + * 배경 지도보다 넓은 범위를 요구하면 배경 크기에 맞춰 멈춘다(빈 여백을 만들지 않는다). + */ +export function computeRouteView( + meta: VWorldMeta | null, + route: PlanBounds | null, + viewportWidth: number, + viewportHeight: number, + marginM: number = ROUTE_VIEW_MARGIN_M, +): { scale: number; offsetX: number; offsetY: number } { + if (!meta || !route) return { scale: 1, offsetX: 0, offsetY: 0 }; + const mapRect = computeMapRect(meta, viewportWidth, viewportHeight); + const wantWidth = Math.max(route.x_max - route.x_min, 1) + marginM * 2; + const wantHeight = Math.max(route.y_max - route.y_min, 1) + marginM * 2; + const scale = Math.max( + Math.min(meta.width_meters / wantWidth, meta.height_meters / wantHeight), + 1, + ); + const centerX = (route.x_min + route.x_max) / 2; + const centerY = (route.y_min + route.y_max) / 2; + const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width; + const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height; + return { + scale, + offsetX: -(baseX - viewportWidth / 2) * scale, + offsetY: -(baseY - viewportHeight / 2) * scale, + }; +} + function isPoint(value: unknown): value is [number, number] { return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number"; } diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 8c271641..791ee668 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -5,13 +5,13 @@ import { fetchGisGeoJson, fetchVWorldMeta, getVWorldMapUrl, - type SurfaceBounds, type VWorldMeta, } from "./B04_wf1_Surface_Api_Fetch"; import { niceScaleDistance } from "./B04_wf1_Surface_UI_Camera"; import { createWatershedOverlay } from "./B04_wf1_Surface_UI_Watershed"; import { computeMapRect, + computeRouteView, createNormalizer, drawPreparedLabels, drawPreparedLayer, @@ -19,13 +19,15 @@ import { type GeoJsonCollection, type MapRect, type Normalizer, + type PlanBounds, type PreparedLayer, type ViewState, } from "./B04_wf1_Surface_UI_MapRender"; export interface SurfaceMapViewer { root: HTMLElement; - render: (projectId: string, referenceBounds?: SurfaceBounds) => void; + /** routeBounds: 계획노선 평면 범위 — 초기 화면을 도로 중심으로 맞추는 데 쓴다. */ + render: (projectId: string, routeBounds?: PlanBounds | null) => void; dispose: () => void; } @@ -171,6 +173,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { let currentProjectId: string | null = null; let meta: VWorldMeta | null = null; + // 초기 화면 기준이 되는 계획노선 범위(B03 CSV). 없으면 배경 전체를 보여준다. + let routeBounds: PlanBounds | null = null; // 배수유역 오버레이가 lon/lat을 화면 좌표로 옮길 때 쓴다. 레이어 로드 시 1회 만든다. let normalizer: Normalizer | null = null; // 사전 투영된 렌더용 레이어. 원본 GeoJSON은 변형하지 않으며 투영 후에는 참조를 잡아두지 않는다. @@ -291,19 +295,26 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { scheduleDraw(); } - /** 확보한 배경 지도(브이월드) 전체가 보이도록 맞춘다. + /** 계획도로가 화면 중앙에 오고 도로 전체 + 여유 200m가 보이도록 맞춘다(B05와 같은 규칙). * - * 3D(라이다)와 2D(브이월드)는 다루는 범위가 다르다 — 라이다는 노선 주변의 좁은 구역, - * 2D 지도는 그보다 훨씬 넓은 주변까지 담는다. 이전에는 2D 지도를 라이다 범위에 맞춰 - * 확대해서, 배경을 넓게 받아도 화면에 보이는 범위가 늘 같았다(2026-08-01 사용자 지적). */ - function fitMapExtent(): void { - scale = 1; - offsetX = 0; - offsetY = 0; + * 라이다 범위에 맞추던 것을 도로 기준으로 바꿨다 — 3D(라이다)와 2D(지도)는 다루는 범위가 + * 달라, 라이다에 맞추면 배경을 넓게 받아도 보이는 범위가 늘 같았다(2026-08-01 사용자 지시). + * 계획노선이 없으면 배경 전체를 그대로 보여준다. */ + function fitRouteView(): void { + const rect = viewport.getBoundingClientRect(); + const view = computeRouteView( + meta, + routeBounds, + Math.max(rect.width, 1), + Math.max(rect.height, 1), + ); + scale = view.scale; + offsetX = view.offsetX; + offsetY = view.offsetY; } function resetView(): void { - fitMapExtent(); + fitRouteView(); updateImageTransform(); scheduleDraw(); } @@ -488,10 +499,10 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { return { root, - // 라이다 범위는 더 이상 화면 배율에 쓰지 않는다(2D는 브이월드 범위 기준). - // 인자는 호출측 호환을 위해 유지한다. - render(projectId) { + // 초기 화면은 계획노선 기준이다(라이다 범위는 쓰지 않는다). + render(projectId, nextRouteBounds) { currentProjectId = projectId; + routeBounds = nextRouteBounds ?? null; watershed.reset(); watershed.setProject(projectId); void loadLayers(); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index 5ea15cd5..2017353d 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -347,11 +347,12 @@ export async function renderB04Surface(root: HTMLElement): Promise { pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value); terrainViewer.setReferenceBounds(pointCloud.bounds); viewer.render(pointCloud); - mapViewer.render(projectId, pointCloud.bounds); + // 지도(2D)는 계획노선 기준으로 연다 — 라이다 범위와 다루는 범위가 다르다. + mapViewer.render(projectId, confirmed.route_bounds); } catch { pointCloud = null; viewer.render(null); - mapViewer.render(projectId); + mapViewer.render(projectId, confirmed.route_bounds); } renderInputInfo(); updateSelectedModel(); 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 c6537f9c..15006d2c 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -7,6 +7,7 @@ import { } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; import { computeMapRect, + computeRouteView, createNormalizer, drawFilledRing, drawPreparedLayer, @@ -485,10 +486,6 @@ export function createDrainagePanel(): DrainagePanel { offsetX = 0; offsetY = 0; if (!meta || routePoints.length < 2) return; - const rect = viewport.getBoundingClientRect(); - const width = Math.max(rect.width, 1); - const height = Math.max(rect.height, 1); - const mapRect = computeMapRect(meta, width, height); let minX = Infinity; let minY = Infinity; let maxX = -Infinity; @@ -499,15 +496,16 @@ export function createDrainagePanel(): DrainagePanel { if (point.y < minY) minY = point.y; if (point.y > maxY) maxY = point.y; }); - const routeWidth = Math.max(maxX - minX, 1); - const routeHeight = Math.max(maxY - minY, 1); - scale = Math.min(meta.width_meters / routeWidth, meta.height_meters / routeHeight) * 0.85; - const centerX = (minX + maxX) / 2; - const centerY = (minY + maxY) / 2; - const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width; - const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height; - offsetX = -(baseX - width / 2) * scale; - offsetY = -(baseY - height / 2) * scale; + const rect = viewport.getBoundingClientRect(); + const view = computeRouteView( + meta, + { x_min: minX, x_max: maxX, y_min: minY, y_max: maxY }, + Math.max(rect.width, 1), + Math.max(rect.height, 1), + ); + scale = view.scale; + offsetX = view.offsetX; + offsetY = view.offsetY; } async function loadLayers(): Promise {