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:
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user