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