From ba18b0f96d30e72efb4cf9b781e3dad1acc3a2a2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 29 Oct 2026 20:21:39 +0900 Subject: [PATCH] =?UTF-8?q?fix(B04=C2=B7B05):=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EA=B2=BD=EC=9C=84=EB=8F=84=20=EA=B2=B9=20=C2=B7=20=EC=9C=84?= =?UTF-8?q?=EC=84=B1=20=EA=B7=B8=EB=A6=BC=EC=9D=84=20TM=20=EA=B2=B9=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=EB=A1=9C=20=EB=A7=9E=EC=B6=A4=20(PLAN=20589-?= =?UTF-8?q?1=20=C2=B7=20=EC=9A=94=EA=B5=AC=20447=20=C2=B7=20448)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 위성 메타 응답에 그림 상자 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 --- B04_PreProcess/B04_PreProcess_Api_Fetch.ts | 2 + .../B04_PreProcess_Engine_VWorld.py | 39 ++++ B04_PreProcess/B04_PreProcess_Router_GIS.py | 13 +- B04_PreProcess/B04_PreProcess_UI_MapRender.ts | 48 ++++- B04_PreProcess/B04_PreProcess_UI_MapViewer.ts | 19 +- .../B04_PreProcess_UI_Style_Map.css | 9 +- B04_PreProcess/B04_PreProcess_UI_Watershed.ts | 52 ++--- .../B04_PreProcess_UI_MapRender_Prepare.ts | 109 +++++++++- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 14 +- .../B05_Profile_UI_Drainage_ZoomRange.ts | 8 +- .../B05_Profile_UI_RouteEdit_Contour.ts | 4 +- B05_Profile/B05_Profile_UI_Style_Drainage.css | 9 +- resources/tester/helper_589_tm_grid.cjs | 33 +++ resources/tester/test_589_tm_grid.py | 195 ++++++++++++++++++ 14 files changed, 475 insertions(+), 79 deletions(-) create mode 100644 resources/tester/helper_589_tm_grid.cjs create mode 100644 resources/tester/test_589_tm_grid.py diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index 9b74f5d61..03f4e0599 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -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 { diff --git a/B04_PreProcess/B04_PreProcess_Engine_VWorld.py b/B04_PreProcess/B04_PreProcess_Engine_VWorld.py index 64f47eae8..08f8031e5 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_VWorld.py +++ b/B04_PreProcess/B04_PreProcess_Engine_VWorld.py @@ -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]} diff --git a/B04_PreProcess/B04_PreProcess_Router_GIS.py b/B04_PreProcess/B04_PreProcess_Router_GIS.py index 42ad22b43..e28f0dbbc 100644 --- a/B04_PreProcess/B04_PreProcess_Router_GIS.py +++ b/B04_PreProcess/B04_PreProcess_Router_GIS.py @@ -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)}) diff --git a/B04_PreProcess/B04_PreProcess_UI_MapRender.ts b/B04_PreProcess/B04_PreProcess_UI_MapRender.ts index 7bcc96ca9..8b3e8d3c2 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapRender.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapRender.ts @@ -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); +} + +/** + * 위성 그림(`` 제 크기 · 원점 왼위)을 화면에 놓음(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"; } /** diff --git a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts index 742e62311..d13e5296a 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts @@ -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; diff --git a/B04_PreProcess/B04_PreProcess_UI_Style_Map.css b/B04_PreProcess/B04_PreProcess_UI_Style_Map.css index 3536d7cc4..ac624c2b8 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Style_Map.css +++ b/B04_PreProcess/B04_PreProcess_UI_Style_Map.css @@ -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; } diff --git a/B04_PreProcess/B04_PreProcess_UI_Watershed.ts b/B04_PreProcess/B04_PreProcess_UI_Watershed.ts index 991b396fc..200f1bff1 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Watershed.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Watershed.ts @@ -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(); diff --git a/B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare.ts b/B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare.ts index 5db9d33d4..4af2541e6 100644 --- a/B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare.ts +++ b/B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare.ts @@ -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]); } /** diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index a86c5b3e1..1b1ac56ac 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -12,7 +12,8 @@ import { import { computeMapRect, MAP_STATION_INTERVAL_M, - createNormalizer, + createTmNormalizer, + placeMapImage, type MapRect, type Normalizer, type PreparedLayer, @@ -186,12 +187,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra redraw: () => scheduleDraw(), }); - function updateImageTransform(): void { - backgroundImage.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; - // 크게 당기면 도엽 그림이 뭉개진다 — 흐림 보간을 끄고 픽셀을 그대로 보인다 - // (2026-09-04 사용자 지시). 축척 막대가 실제 크기를 알려 준다. - backgroundImage.style.imageRendering = scale > 4 ? "pixelated" : "auto"; - } + backgroundImage.addEventListener("load", () => scheduleDraw()); function draw(): void { const rect = viewport.getBoundingClientRect(); @@ -238,7 +234,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra stationIntervalM: MAP_STATION_INTERVAL_M, intervalSpans, }); - updateImageTransform(); + placeMapImage(backgroundImage, meta, normalizer, view); } /** 현재 프레임 뷰 상태 (draw()와 동일 계산 — 배관 마커 포인터 히트 판정용). */ @@ -474,7 +470,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra ); if (sequence !== loadSequence) return; meta = nextMeta; - normalizer = createNormalizer(nextMeta); + normalizer = createTmNormalizer(nextMeta); let featureCount = 0; loaded.forEach(([layer, data]) => { if (!data) return; diff --git a/B05_Profile/B05_Profile_UI_Drainage_ZoomRange.ts b/B05_Profile/B05_Profile_UI_Drainage_ZoomRange.ts index 85846e75e..640826528 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_ZoomRange.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_ZoomRange.ts @@ -12,8 +12,9 @@ import type { DetailBasin, VWorldMeta } from "../B04_PreProcess/B04_PreProcess_A import { computeMapRect, computeRouteView, - createNormalizer, + createTmNormalizer, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import { normOfLonLat } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare"; /** 노선 범위를 몇 배까지 멀리 볼지 — 키우면 더 멀리 빠진다(사용자가 바꿀 수 있는 값). */ export const ZOOM_OUT_ROUTE_FACTOR = 2; @@ -50,11 +51,10 @@ export function computeZoomOutLimit( let x1 = centerX + halfW; let y0 = centerY - halfH; let y1 = centerY + halfH; - const normalizer = createNormalizer(meta); + const normalizer = createTmNormalizer(meta); basins.forEach((basin) => basin.polygon_lonlat.forEach(([lon, lat]) => { - const nx = (lon - normalizer.lonMin) / normalizer.lonRange; - const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange; + const [nx, ny] = normOfLonLat(normalizer, lon, lat); x0 = Math.min(x0, nx); x1 = Math.max(x1, nx); y0 = Math.min(y0, ny); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts index 84796ff7d..5c233b6cf 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts @@ -13,7 +13,7 @@ import { contourAssetUrl, fetchCachedJson } from "../A00_Common/b_asset_cache"; import { - createNormalizer, + createTmNormalizer, type GeoJsonCollection, type Normalizer, type PreparedLayer, @@ -192,7 +192,7 @@ export async function loadRouteEditBackground( reuse: RouteEditContours | null = null, ): Promise<{ otherSheets: PreparedLayer[]; contours: RouteEditContours; sheets: SheetLayers }> { const drainage = await coveringSheets(projectId, clipped); - const normalizer = createNormalizer(drainage.meta); + const normalizer = createTmNormalizer(drainage.meta); const otherSheets = drainage.layers .filter(([layer]) => layer !== "도엽_등고선") .map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null)) diff --git a/B05_Profile/B05_Profile_UI_Style_Drainage.css b/B05_Profile/B05_Profile_UI_Style_Drainage.css index ca8469929..599e0be63 100644 --- a/B05_Profile/B05_Profile_UI_Style_Drainage.css +++ b/B05_Profile/B05_Profile_UI_Style_Drainage.css @@ -119,9 +119,14 @@ height: 100%; } +/* 그림은 제 크기 · 원점 왼위 — 자리는 TM 겹에 맞춘 행렬(`placeMapImage` · 589-1). */ .b05-drainage__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; } diff --git a/resources/tester/helper_589_tm_grid.cjs b/resources/tester/helper_589_tm_grid.cjs new file mode 100644 index 000000000..450dbd64e --- /dev/null +++ b/resources/tester/helper_589_tm_grid.cjs @@ -0,0 +1,33 @@ +/* 589-1 경위도 겹 → TM 자리 헬퍼 — `B04_PreProcess_UI_MapRender(_Prepare).ts` 의 `createTmNormalizer` · + * `normOfLonLat` · `imageScreenMatrix` 를 stdin 입력으로 돌려 JSON 으로 냄. + * 입력 { meta, lonlat: [[lon, lat]], image: [w, h], views: [[w, h, scale, ox, oy]] } + */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const { load } = makeLoader({ root: ROOT, ui: () => ({ themeColor: () => "" }) }); +const render = load("B04_PreProcess", "B04_PreProcess_UI_MapRender.ts"); +const prep = load("B04_PreProcess", "logic", "B04_PreProcess_UI_MapRender_Prepare.ts"); + +const input = JSON.parse(require("fs").readFileSync(0, "utf-8")); +const { meta } = input; +const tmNorm = render.createTmNormalizer(meta); +const oldNorm = render.createNormalizer(meta); +const unit = { width: 1, height: 1, scale: 1, offsetX: 0, offsetY: 0 }; +unit.mapRect = { x: 0, y: 0, width: 1, height: 1 }; +const out = { + hasToNorm: typeof tmNorm.toNorm === "function", + tm: input.lonlat.map(([lon, lat]) => prep.normOfLonLat(tmNorm, lon, lat)), + old: input.lonlat.map(([lon, lat]) => prep.normOfLonLat(oldNorm, lon, lat)), + oldScreen: input.lonlat.map(([lon, lat]) => render.lonLatToScreen(oldNorm, unit, lon, lat)), + views: input.views.map(([w, h, scale, ox, oy]) => { + const mapRect = render.computeMapRect(meta, w, h); + const view = { width: w, height: h, scale, offsetX: ox, offsetY: oy, mapRect }; + const [iw, ih] = input.image; + const toScreen = (nx, ny) => render.normalizedToScreen(view, nx, ny); + const matrix = prep.imageScreenMatrix(meta, tmNorm, iw, ih, toScreen, w / 2, h / 2); + return { mapRect, matrix }; + }), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_589_tm_grid.py b/resources/tester/test_589_tm_grid.py new file mode 100644 index 000000000..b03f0e321 --- /dev/null +++ b/resources/tester/test_589_tm_grid.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +"""589-1 B04 · B05 지도 — 경위도 겹(도엽 · 지적 · 유역 · 위성 그림)을 TM 겹(노선 · 관) 자리에. + +- 서버 `tm_grid_for_meta` 마디 = pyproj · 왼위 · 오른아래 마디 = 메타 귀퉁이 1 mm 안(Bessel · GRS80) +- 메타 귀퉁이와 다른 좌표계면 격자 안 줌 +- TS `normOfLonLat`(격자 쌍선형) = pyproj TM 자리 0.02 m 안 · 격자 없으면 옛 같은 네모 식 그대로 +- 위성 그림 행렬(화면 가운데 1차) = 그림 px → 경위도 → pyproj TM 자리와 화면 안 0.5 px 안 +- 옛 같은 네모 꼴은 귀퉁이에서 용화 ≥ 3 m · D ≥ 60 m 어긋남(고친 까닭) + +Node 헬퍼(`helper_589_tm_grid.cjs`)가 진짜 TS 를 돌린다. +""" + +import json +import math +import os +import subprocess + +import pytest +from pyproj import Transformer + +from B04_PreProcess.B04_PreProcess_Engine_VWorld import tm_grid_for_meta + +HERE = os.path.dirname(os.path.abspath(__file__)) + +BESSEL = ( + 'PROJCS["Korean 1985 / Modified East Belt",GEOGCS["Korean 1985",DATUM["Korean Datum 1985",' + 'SPHEROID["Bessel 1841",6377397.155,299.1528128],TOWGS84[-114.61998513841,475.96296986986,' + '675.01832868573,1.162,-2.347,-1.592,6.342]],PRIMEM["Greenwich",0],UNIT["degree",' + '0.01745329251994328]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",38],' + 'PARAMETER["central_meridian",129.002890277778],PARAMETER["scale_factor",1],' + 'PARAMETER["false_easting",200000],PARAMETER["false_northing",500000],UNIT["metre",1]]' +) +# 검증 B(용화 · Bessel) · D(5186) 위성 상자(lon_min, lon_max, lat_min, lat_max) · 그림 px +SITES = { + "B": ( + BESSEL, + (129.0728759765625, 129.100341796875, 36.798288873837045, 36.82687474287728), + (2560, 3328), + ), + "D": ( + "EPSG:5186", + (127.9193115234375, 127.979736328125, 36.270850207239036, 36.30184530368433), + (2816, 1792), + ), +} +VIEWS = [(1400, 560, 1, 0, 0), (1400, 560, 2.33, -120, 80), (1400, 560, 300, -60000, 40000)] + + +def _meta(crs, box): + """서버 식 그대로 — 왼위 · 오른아래 두 귀퉁이만 TM 으로.""" + lon_min, lon_max, lat_min, lat_max = box + fwd = Transformer.from_crs("EPSG:4326", crs, always_xy=True) + x_min, y_max = fwd.transform(lon_min, lat_max) + x_max, y_min = fwd.transform(lon_max, lat_min) + return { + "x_min": x_min, + "x_max": x_max, + "y_min": y_min, + "y_max": y_max, + "width_meters": x_max - x_min, + "height_meters": y_max - y_min, + "center_x": (x_min + x_max) / 2, + "center_y": (y_min + y_max) / 2, + "lon_min": lon_min, + "lon_max": lon_max, + "lat_min": lat_min, + "lat_max": lat_max, + } + + +def _merc(lat): + return math.log(math.tan(math.pi / 4 + math.radians(lat) / 2)) + + +def _lonlat_of_px(meta, size, u, v): + """그림 px → 경위도(가로 경도 선형 · 세로 메르카토르 y 선형).""" + top, bottom = _merc(meta["lat_max"]), _merc(meta["lat_min"]) + lon = meta["lon_min"] + u / size[0] * (meta["lon_max"] - meta["lon_min"]) + m = top - v / size[1] * (top - bottom) + return lon, math.degrees(2 * math.atan(math.exp(m)) - math.pi / 2) + + +def _run(site, with_grid=True): + crs, box, size = SITES[site] + meta = _meta(crs, box) + if with_grid: + meta["tm_grid"] = tm_grid_for_meta(meta, crs) + lon_min, lon_max, lat_min, lat_max = box + lonlat = [ + (lon_min + a / 10 * (lon_max - lon_min), lat_min + b / 10 * (lat_max - lat_min)) + for a in range(11) + for b in range(11) + ] + payload = {"meta": meta, "lonlat": lonlat, "image": size, "views": VIEWS} + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_589_tm_grid.cjs")], + input=json.dumps(payload), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return meta, lonlat, json.loads(proc.stdout) + + +@pytest.fixture(scope="module", params=["B", "D"]) +def site(request): + return request.param, *_run(request.param) + + +def test_서버_격자는_pyproj_마디(site): + name, meta, _, _ = site + grid = meta["tm_grid"] + n = grid["n"] + assert n == 9 and len(grid["x"]) == n * n + assert abs(grid["x"][n * (n - 1)] - meta["x_min"]) < 1e-3 + assert abs(grid["y"][n * (n - 1)] - meta["y_max"]) < 1e-3 + assert abs(grid["x"][n - 1] - meta["x_max"]) < 1e-3 + assert abs(grid["y"][n - 1] - meta["y_min"]) < 1e-3 + fwd = Transformer.from_crs("EPSG:4326", SITES[name][0], always_xy=True) + i, j = 3, 5 + lon = meta["lon_min"] + i / (n - 1) * (meta["lon_max"] - meta["lon_min"]) + lat = meta["lat_min"] + j / (n - 1) * (meta["lat_max"] - meta["lat_min"]) + x, y = fwd.transform(lon, lat) + assert math.hypot(grid["x"][j * n + i] - x, grid["y"][j * n + i] - y) < 1e-3 + + +def test_다른_좌표계_메타면_격자_안_줌(): + meta = _meta("EPSG:5186", SITES["D"][1]) + assert tm_grid_for_meta(meta, "EPSG:5187") is None + + +def test_TS_격자_자리는_pyproj_TM(site): + name, meta, lonlat, out = site + assert out["hasToNorm"] + fwd = Transformer.from_crs("EPSG:4326", SITES[name][0], always_xy=True) + worst = 0.0 + for (lon, lat), (nx, ny) in zip(lonlat, out["tm"]): + x, y = fwd.transform(lon, lat) + gx = meta["x_min"] + nx * meta["width_meters"] + gy = meta["y_min"] + (1 - ny) * meta["height_meters"] + worst = max(worst, math.hypot(gx - x, gy - y)) + assert worst < 0.02, worst + + +def test_격자_없으면_옛_식_그대로(): + meta, lonlat, out = _run("B", with_grid=False) + assert not out["hasToNorm"] + for (lon, lat), tm, old, screen in zip(lonlat, out["tm"], out["old"], out["oldScreen"]): + want = [ + (lon - meta["lon_min"]) / (meta["lon_max"] - meta["lon_min"]), + 1 - (lat - meta["lat_min"]) / (meta["lat_max"] - meta["lat_min"]), + ] + assert tm == want and old == want and screen == want + + +def test_위성_그림_행렬은_TM_자리(site): + name, meta, _, out = site + crs, _, size = SITES[name] + fwd = Transformer.from_crs("EPSG:4326", crs, always_xy=True) + for (w, h, scale, ox, oy), got in zip(VIEWS, out["views"]): + rect = got["mapRect"] + a, b, c, d, e, f = got["matrix"] + ax, ay = rect["width"] * scale, rect["height"] * scale + bx = rect["x"] * scale + w / 2 * (1 - scale) + ox + by = rect["y"] * scale + h / 2 * (1 - scale) + oy + if scale < 10: + # 그림 귀퉁이 · 가장자리 가운데 · 가운데 + probes = [(u, v) for u in (0, size[0] / 2, size[0]) for v in (0, size[1] / 2, size[1])] + else: + # 크게 당김 — 화면 귀퉁이 · 가운데에 놓인 그림 px + det = a * d - b * c + probes = [] + for sx, sy in ((0, 0), (w, 0), (0, h), (w, h), (w / 2, h / 2)): + du, dv = sx - e, sy - f + probes.append(((d * du - c * dv) / det, (a * dv - b * du) / det)) + for u, v in probes: + x, y = fwd.transform(*_lonlat_of_px(meta, size, u, v)) + want = ( + (x - meta["x_min"]) / meta["width_meters"] * ax + bx, + (1 - (y - meta["y_min"]) / meta["height_meters"]) * ay + by, + ) + got_px = (a * u + c * v + e, b * u + d * v + f) + gap = math.hypot(got_px[0] - want[0], got_px[1] - want[1]) + assert gap < 0.5, (name, scale, u, v, gap) + + +@pytest.mark.parametrize("name,least", [("B", 3.0), ("D", 60.0)]) +def test_옛_같은_네모_꼴은_귀퉁이에서_어긋남(name, least): + crs, box, _ = SITES[name] + meta = _meta(crs, box) + x, y = Transformer.from_crs("EPSG:4326", crs, always_xy=True).transform(box[1], box[3]) + # 옛 식 = 오른위 귀퉁이 경위도를 상자 오른위(x_max, y_max) 에 둠 + assert math.hypot(meta["x_max"] - x, meta["y_max"] - y) >= least