Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-29 20:31:54 +09:00
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]);
}
/**
+5 -9
View File
@@ -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;
}
+33
View File
@@ -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));
+195
View File
@@ -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