fix(B04·B05): 지도 경위도 겹 · 위성 그림을 TM 겹 자리로 맞춤 (PLAN 589-1 · 요구 447 · 448)

- 서버 위성 메타 응답에 그림 상자 9×9 TM 격자(tm_grid · pyproj · 응답 때 셈 · 귀퉁이 1 cm 넘게 다르면 안 줌)
- 공용 createTmNormalizer(고를 칸 toNorm · 격자 쌍선형) · normOfLonLat · imageScreenMatrix · placeMapImage
- B04 하단 지도 · B05 배수유역도 · B05 평면 노선 편집이 TM 자리로 · 위성 그림 = 화면 가운데 1차 행렬(contain 뺌)
- Watershed 손 베낌 식 넷 · 줌아웃 한계 = 공용 식 · D03 길(createNormalizer) 바뀜 없음 · TM 길 손 안 댐
- 지킴 시험 test_589_tm_grid(Bessel · GRS80 · 격자 = pyproj 0.02 m · 그림 행렬 0.5 px · 옛 꼴 어긋남)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 20:21:39 +09:00
co-authored by Claude Opus 5.5
parent 7beca06a06
commit ba18b0f96d
14 changed files with 475 additions and 79 deletions
@@ -325,6 +325,8 @@ export interface VWorldMeta {
lon_max: number;
lat_min: number;
lat_max: number;
/** 그림 상자 경위도 n×n 고른 마디의 TM 값(`[j * n + i]` · j 남 → 북 · 589-1) — 서버가 응답 때 붙임. */
tm_grid?: { n: number; x: number[]; y: number[] };
}
export function getVWorldMapUrl(projectId: string, layerName: string): string {
@@ -195,3 +195,42 @@ def download_vworld_satellite_map(
meta_json_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
return map_png_path
# 그림 상자 경위도 격자 한 변 마디 수 — 쌍선형 나머지 용화 0.001 · D 0.006 · 큰 상자 0.014 m(589-1).
TM_GRID_NODES = 9
def tm_grid_for_meta(meta: dict, crs_input: str, nodes: int = TM_GRID_NODES) -> dict | None:
"""그림 상자 경위도 고른 마디의 TM 값(589-1 · 화면이 경위도 겹을 TM 자리로 놓음).
마디 `[j * n + i]` = 경도 `lon_min + i/(n-1)·폭` · 위도 `lat_min + j/(n-1)·높이`(j 는 남 → 북).
왼위 · 오른아래 마디가 메타 `x_min · y_max` · `x_max · y_min` 과 1 cm 넘게 다르면(메타를 다른
좌표계로 만든 것) 격자를 주지 않음 — 화면은 옛 같은 네모 식으로 남음.
"""
keys = ("lon_min", "lon_max", "lat_min", "lat_max", "x_min", "x_max", "y_min", "y_max")
if any(not isinstance(meta.get(key), (int, float)) for key in keys):
return None
transformer = Transformer.from_crs("EPSG:4326", crs_input, always_xy=True)
lon_span = meta["lon_max"] - meta["lon_min"]
lat_span = meta["lat_max"] - meta["lat_min"]
lons = [meta["lon_min"] + i / (nodes - 1) * lon_span for i in range(nodes)]
lats = [meta["lat_min"] + j / (nodes - 1) * lat_span for j in range(nodes)]
xs: list[float] = []
ys: list[float] = []
for lat in lats:
x_row, y_row = transformer.transform(lons, [lat] * nodes)
xs.extend(float(v) for v in x_row)
ys.extend(float(v) for v in y_row)
top_left = nodes * (nodes - 1)
bottom_right = nodes - 1
off = max(
abs(xs[top_left] - meta["x_min"]),
abs(ys[top_left] - meta["y_max"]),
abs(xs[bottom_right] - meta["x_max"]),
abs(ys[bottom_right] - meta["y_min"]),
)
if not math.isfinite(off) or off > 0.01:
logger.warning("위성 메타 귀퉁이와 TM 격자가 %.3f m 어긋남 — 격자 안 줌", off)
return None
return {"n": nodes, "x": [round(v, 4) for v in xs], "y": [round(v, 4) for v in ys]}
+12 -1
View File
@@ -9,6 +9,7 @@ from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_crs import resolve_project_crs
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_json import LONLAT_DIGITS, round_floats
from common_util.common_util_storage import resolve_stored_project_path
@@ -25,6 +26,7 @@ from B04_PreProcess.B04_PreProcess_Engine_SheetClip import ( # noqa: E402
# 수치지형도 도엽 병합 레이어 (SheetParser 산출과 1:1 — 정의는 SHEET_LAYERS가 유일)
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import SHEET_LAYERS # noqa: E402
from B04_PreProcess.B04_PreProcess_Engine_VWorld import tm_grid_for_meta # noqa: E402
_SHEET_GEOJSON_FILES = {f"도엽_{k}": f"도엽_{k}.geojson" for k in SHEET_LAYERS}
@@ -57,7 +59,16 @@ async def get_vworld_meta(
"message": f"VWorld {layer_name} 메타데이터를 찾을 수 없습니다.",
},
)
return json.loads(meta_path.read_text(encoding="utf-8"))
meta = json.loads(meta_path.read_text(encoding="utf-8"))
# 경위도 겹을 TM 자리로 놓을 격자(589-1) — 응답 때 셈 · 파일에 안 씀 · 모르는 화면은 무시.
try:
grid = tm_grid_for_meta(meta, resolve_project_crs(project_root))
except Exception:
logger.warning("위성 메타 TM 격자 셈 실패 — 격자 없이 응답", exc_info=True)
grid = None
if grid is not None:
meta["tm_grid"] = grid
return meta
except Exception as exc:
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
+43 -5
View File
@@ -1,6 +1,11 @@
import { themeColor } from "@ui/ui_template_palette";
import type { VWorldMeta } from "./B04_PreProcess_Api_Fetch";
import { pickLodList } from "./logic/B04_PreProcess_UI_MapRender_Prepare";
import {
imageScreenMatrix,
normOfLonLat,
pickLodList,
tmGridNormOf,
} from "./logic/B04_PreProcess_UI_MapRender_Prepare";
// 2D 지도 벡터 레이어 렌더 엔진.
// GeoJSON 좌표를 로드 시 1회만 정규화 맵 좌표(0~1)로 사전 투영해 두고,
@@ -84,6 +89,8 @@ export type Normalizer = {
lonRange: number;
latRange: number;
aspect: number;
/** 경위도 → 정규화 고를 칸(589-1 · `createTmNormalizer`) — 있으면 TM 자리 · 없으면 같은 네모 식. */
toNorm?: (lon: number, lat: number) => [number, number];
};
/** 뷰포트 안에서 지도 이미지가 차지하는 사각형(기존 getMapRect와 동일 계산). */
@@ -114,6 +121,13 @@ export function createNormalizer(meta: VWorldMeta): Normalizer {
};
}
/** 경위도 겹을 TM 겹 자리로 놓는 변환 계수(589-1 · B04 · B05) — 메타에 `tm_grid` 가 없으면 `createNormalizer` 그대로. */
export function createTmNormalizer(meta: VWorldMeta): Normalizer {
const base = createNormalizer(meta);
const toNorm = tmGridNormOf(meta);
return toNorm ? { ...base, toNorm } : base;
}
export function computeMapRect(meta: VWorldMeta | null, width: number, height: number): MapRect {
if (!meta) return { x: 0, y: 0, width, height };
const mapRatio = meta.width_meters / Math.max(meta.height_meters, 1);
@@ -338,11 +352,35 @@ export function lonLatToScreen(
lon: number,
lat: number,
): [x: number, y: number] {
return normalizedToScreen(
view,
(lon - normalizer.lonMin) / normalizer.lonRange,
1 - (lat - normalizer.latMin) / normalizer.latRange,
const [nx, ny] = normOfLonLat(normalizer, lon, lat);
return normalizedToScreen(view, nx, ny);
}
/**
* 위성 그림(`<img>` 제 크기 · 원점 왼위)을 화면에 놓음(589-1) — 그림 px → 경위도 → `normalizer` → 화면을
* 화면 가운데에서 1차로 맞춘 행렬. 그림 · 메타가 아직 없으면 그대로 둠.
*/
export function placeMapImage(
image: HTMLImageElement,
meta: VWorldMeta | null,
normalizer: Normalizer | null,
view: ViewState,
): void {
if (!meta || !normalizer || !image.naturalWidth || !image.naturalHeight) return;
const affine = affineOf(view);
const matrix = imageScreenMatrix(
meta,
normalizer,
image.naturalWidth,
image.naturalHeight,
(nx, ny) => [nx * affine.ax + affine.bx, ny * affine.ay + affine.by],
view.width / 2,
view.height / 2,
);
image.style.transform = `matrix(${matrix.join(", ")})`;
// 크게 당기면 배경 그림이 뭉개진다 — 흐림 보간을 끄고 픽셀을 그대로 보인다
// (2026-09-04 사용자 지시). 실제 크기는 축척 막대로 읽는다.
image.style.imageRendering = view.scale > 4 ? "pixelated" : "auto";
}
/**
+8 -11
View File
@@ -34,9 +34,10 @@ import {
computeRouteView,
createMetricProjector,
MAP_STATION_INTERVAL_M,
createNormalizer,
createTmNormalizer,
drawPreparedLabels,
drawPreparedLayer,
placeMapImage,
routeLineColor,
ROUTE_LINE_WIDTH,
type MapRect,
@@ -108,6 +109,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
image.className = "b04-map__image";
image.alt = L("B04_Surface_Map_ImageAlt");
image.draggable = false;
image.addEventListener("load", () => scheduleDraw());
backgroundImages.set(layer, image);
});
const canvas = document.createElement("canvas");
@@ -308,13 +310,9 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
// 강도 색띠 범례도 같은 자리에 얹는다(CSS가 우측 세로로 세운다).
viewport.append(detailBasins.menuElement, flowStrength.legendElement);
function updateImageTransform(): void {
backgroundImages.forEach((image) => {
image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
// 크게 당기면 배경 그림이 뭉개진다 — 흐림 보간을 끄고 픽셀을 그대로 보인다
// (2026-09-04 사용자 지시). 실제 크기는 축척 막대로 읽는다.
image.style.imageRendering = scale > 4 ? "pixelated" : "auto";
});
/** 배경 그림을 TM 겹 자리로 놓음(589-1 · 공용 `placeMapImage`). */
function updateImageTransform(view: ViewState): void {
backgroundImages.forEach((image) => placeMapImage(image, meta, normalizer, view));
}
function syncLayerVisibility(): void {
@@ -346,7 +344,6 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
function resetView(): void {
fitRouteView();
updateImageTransform();
scheduleDraw();
}
@@ -432,7 +429,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
detailBasins.draw(context, normalizer, view);
// 유입 집중점(집중유역)은 맨 마지막 — 켰을 때 무엇에도 가리지 않아야 고를 수 있다.
flowStrength.drawTop(context, normalizer, view);
updateImageTransform();
updateImageTransform(view);
drawScaleBar(mapRect);
}
@@ -489,7 +486,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
if (sequence !== loadSequence) return;
meta = nextMeta;
// 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다.
normalizer = createNormalizer(nextMeta);
normalizer = createTmNormalizer(nextMeta);
routeLayer =
planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null;
routePoints = planned.points;
@@ -77,9 +77,14 @@
height: 100%;
}
/* 그림은 제 크기 · 원점 왼위 — 자리는 TM 겹에 맞춘 행렬(`placeMapImage` · 589-1). */
.b04-map__image {
object-fit: contain;
transform-origin: center;
inset: 0 auto auto 0;
width: auto;
height: auto;
max-width: none;
max-height: none;
transform-origin: 0 0;
user-select: none;
pointer-events: none;
}
+15 -37
View File
@@ -3,7 +3,7 @@ import { themeColor } from "@ui/ui_template_palette";
import { createLayerToggle, setLayerLabel } from "./B04_PreProcess_UI_LayerToggle";
import { fetchWatershedAnalysis, type WatershedAnalysis } from "./B04_PreProcess_Api_Fetch";
import { drawFlowArrows } from "./B04_PreProcess_UI_FlowArrows";
import type { Normalizer, ViewState } from "./B04_PreProcess_UI_MapRender";
import { lonLatToScreen, type Normalizer, type ViewState } from "./B04_PreProcess_UI_MapRender";
import { drawUpstreamLines } from "./B04_PreProcess_UI_MapOverlays";
function L(key: keyof typeof ui_locales): string {
@@ -167,14 +167,9 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
view: ViewState,
): void {
if (line.length < 2) return;
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
context.beginPath();
line.forEach(([lon, lat], index) => {
const x = ((lon - map.lonMin) / map.lonRange) * ax + bx;
const y = (1 - (lat - map.latMin) / map.latRange) * ay + by;
const [x, y] = lonLatToScreen(map, view, lon, lat);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
@@ -205,16 +200,12 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
): void {
const ring = region.grid.bbox_lonlat;
if (ring.length < 4) return;
const lons = ring.map(([lon]) => lon);
const lats = ring.map(([, lat]) => lat);
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
const left = ((Math.min(...lons) - map.lonMin) / map.lonRange) * ax + bx;
const right = ((Math.max(...lons) - map.lonMin) / map.lonRange) * ax + bx;
const top = (1 - (Math.max(...lats) - map.latMin) / map.latRange) * ay + by;
const bottom = (1 - (Math.min(...lats) - map.latMin) / map.latRange) * ay + by;
// 격자 귀퉁이를 화면으로 옮긴 뒤 감싸는 네모 — TM 1 m 격자는 TM 자리(589-1)에서 곧은 네모.
const corners = ring.map(([lon, lat]) => lonLatToScreen(map, view, lon, lat));
const left = Math.min(...corners.map(([x]) => x));
const right = Math.max(...corners.map(([x]) => x));
const top = Math.min(...corners.map(([, y]) => y));
const bottom = Math.max(...corners.map(([, y]) => y));
const { rows, cols, row_spans: spans } = region.grid;
const cellW = (right - left) / Math.max(cols, 1);
@@ -386,24 +377,16 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
view: ViewState,
region: WatershedAnalysis,
): void {
// 격자 bbox의 경도 폭과 실폭(m)으로 1m당 픽셀을 환산한다.
const lons = region.grid.bbox_lonlat.map(([lon]) => lon);
const spanLon = Math.max(...lons) - Math.min(...lons);
if (!(spanLon > 0) || !(region.grid.width_m > 0)) return;
const ax = view.mapRect.width * view.scale;
const pxPerMeter = ((spanLon / map.lonRange) * ax) / region.grid.width_m;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
// 격자 bbox의 화면 폭과 실폭(m)으로 1m당 픽셀을 환산한다.
const xs = region.grid.bbox_lonlat.map(([lon, lat]) => lonLatToScreen(map, view, lon, lat)[0]);
const spanPx = Math.max(...xs) - Math.min(...xs);
if (!(spanPx > 0) || !(region.grid.width_m > 0)) return;
drawFlowArrows(
context,
region.flow_arrows ?? [],
region.arrow_spacing_m ?? 0,
pxPerMeter,
(lon, lat) => [
((lon - map.lonMin) / map.lonRange) * ax + bx,
(1 - (lat - map.latMin) / map.latRange) * ay + by,
],
spanPx / region.grid.width_m,
(lon, lat) => lonLatToScreen(map, view, lon, lat),
view,
);
}
@@ -423,13 +406,8 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
strokeLonLat(context, region.basin_polygon_lonlat, map, view);
}
context.setLineDash([]);
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
region.pipes.forEach((pipe, index) => {
const x = ((pipe.lon - map.lonMin) / map.lonRange) * ax + bx;
const y = (1 - (pipe.lat - map.latMin) / map.latRange) * ay + by;
const [x, y] = lonLatToScreen(map, view, pipe.lon, pipe.lat);
context.beginPath();
context.arc(x, y, 7, 0, Math.PI * 2);
context.fillStyle = pipeMarkerColor();
@@ -51,6 +51,105 @@ export function indexLayerLod(layer: PreparedLayer): PreparedLayer {
return layer;
}
/** 경위도 → 정규화(0~1) — 고를 칸 `toNorm` 이 있으면 그것(TM 자리) · 없으면 같은 네모 식. */
export function normOfLonLat(normalizer: Normalizer, lon: number, lat: number): [number, number] {
if (normalizer.toNorm) return normalizer.toNorm(lon, lat);
return [
(lon - normalizer.lonMin) / normalizer.lonRange,
1 - (lat - normalizer.latMin) / normalizer.latRange,
];
}
/**
* 경위도 → TM 정규화(589-1) — 서버 `tm_grid`(그림 상자 경위도 고른 마디의 TM 값) 쌍선형 →
* `((x − x_min)/W, 1 − (y − y_min)/H)` = `metricToScreen` 과 같은 자. 상자 밖은 가장자리 칸을 늘려 씀.
* 격자가 없거나 모양이 틀리면 null.
*/
export function tmGridNormOf(
meta: VWorldMeta,
): ((lon: number, lat: number) => [number, number]) | null {
const grid = meta.tm_grid;
const n = grid?.n ?? 0;
if (!grid || n < 2 || grid.x.length !== n * n || grid.y.length !== n * n) return null;
const { x: gx, y: gy } = grid;
const cells = n - 1;
const lonRange = meta.lon_max - meta.lon_min || 1;
const latRange = meta.lat_max - meta.lat_min || 1;
const spanX = meta.width_meters || 1;
const spanY = meta.height_meters || 1;
return (lon, lat) => {
const u = ((lon - meta.lon_min) / lonRange) * cells;
const v = ((lat - meta.lat_min) / latRange) * cells;
const i = Math.min(Math.max(Math.floor(u), 0), cells - 1);
const j = Math.min(Math.max(Math.floor(v), 0), cells - 1);
const fu = u - i;
const fv = v - j;
const k = j * n + i;
const w00 = (1 - fu) * (1 - fv);
const w10 = fu * (1 - fv);
const w01 = (1 - fu) * fv;
const w11 = fu * fv;
const x = w00 * gx[k] + w10 * gx[k + 1] + w01 * gx[k + n] + w11 * gx[k + n + 1];
const y = w00 * gy[k] + w10 * gy[k + 1] + w01 * gy[k + n] + w11 * gy[k + n + 1];
return [(x - meta.x_min) / spanX, 1 - (y - meta.y_min) / spanY];
};
}
const mercatorOf = (lat: number): number => Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360));
const latOfMercator = (m: number): number => (Math.atan(Math.exp(m)) * 360) / Math.PI - 90;
/**
* 위성 그림 px → 화면 CSS 행렬 `[a, b, c, d, e, f]`(원점 그림 왼위 · 589-1).
* 그림 = 웹 메르카토르 타일 이음(가로 경도 선형 · 세로 메르카토르 y 선형) → 경위도 → `normalizer` →
* `toScreen`. 이 길을 화면 (`centerX`, `centerY`) 에 닿는 그림 px 에서 1차로 맞춤 — 옮김 · 확대마다 다시 셈.
*/
export function imageScreenMatrix(
meta: VWorldMeta,
normalizer: Normalizer,
imageWidth: number,
imageHeight: number,
toScreen: (nx: number, ny: number) => [number, number],
centerX: number,
centerY: number,
): [number, number, number, number, number, number] {
const lonRange = meta.lon_max - meta.lon_min;
const mTop = mercatorOf(meta.lat_max);
const mSpan = mTop - mercatorOf(meta.lat_min);
const screenOf = (u: number, v: number): [number, number] => {
const lon = meta.lon_min + (u / imageWidth) * lonRange;
const lat = latOfMercator(mTop - (v / imageHeight) * mSpan);
const [nx, ny] = normOfLonLat(normalizer, lon, lat);
return toScreen(nx, ny);
};
let u = imageWidth / 2;
let v = imageHeight / 2;
let a = 1;
let b = 0;
let c = 0;
let d = 1;
let at = screenOf(u, v);
// 뉴턴 셋 — 길이 거의 1차라 한두 번에 화면 가운데 그림 px 가 잡힘.
for (let step = 0; step < 3; step += 1) {
const h = 0.5;
const du0 = screenOf(u - h, v);
const du1 = screenOf(u + h, v);
const dv0 = screenOf(u, v - h);
const dv1 = screenOf(u, v + h);
a = (du1[0] - du0[0]) / (2 * h);
b = (du1[1] - du0[1]) / (2 * h);
c = (dv1[0] - dv0[0]) / (2 * h);
d = (dv1[1] - dv0[1]) / (2 * h);
const det = a * d - b * c;
if (!Number.isFinite(det) || Math.abs(det) < 1e-12) break;
const ex = centerX - at[0];
const ey = centerY - at[1];
u += (d * ex - c * ey) / det;
v += (a * ey - b * ex) / det;
at = screenOf(u, v);
}
return [a, b, c, d, at[0] - a * u - c * v, at[1] - b * u - d * v];
}
function isPoint(value: unknown): value is [number, number] {
return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number";
}
@@ -62,8 +161,9 @@ function projectRing(ring: unknown, normalizer: Normalizer): Float64Array | null
let count = 0;
for (const point of ring) {
if (!isPoint(point)) continue;
coords[count * 2] = (point[0] - normalizer.lonMin) / normalizer.lonRange;
coords[count * 2 + 1] = 1 - (point[1] - normalizer.latMin) / normalizer.latRange;
const [nx, ny] = normOfLonLat(normalizer, point[0], point[1]);
coords[count * 2] = nx;
coords[count * 2 + 1] = ny;
count += 1;
}
if (count === 0) return null;
@@ -166,10 +266,7 @@ function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [numb
if (!Array.isArray(line) || line.length === 0) return null;
const mid = line[Math.floor(line.length / 2)];
if (!isPoint(mid)) return null;
return [
(mid[0] - normalizer.lonMin) / normalizer.lonRange,
1 - (mid[1] - normalizer.latMin) / normalizer.latRange,
];
return normOfLonLat(normalizer, mid[0], mid[1]);
}
/**