feat(B04/B05): 지도 초기 화면을 도로 중심 + 여유 200m로 통일

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 13:08:11 +09:00
co-authored by Claude Opus 5
parent 306268b616
commit 85eda04a8e
8 changed files with 128 additions and 29 deletions
@@ -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 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환.
@@ -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:
"""저장된 배경 지도가 필요한 범위를 이미 덮고 있는가.
@@ -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)})
@@ -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):
@@ -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";
}
+25 -14
View File
@@ -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();
+3 -2
View File
@@ -347,11 +347,12 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
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();
@@ -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<void> {