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