5 Commits
Author SHA1 Message Date
eomsangdon d63aae0e9c Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-29 20:31:54 +09:00
eomsangdon a9390ca6e0 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-29 20:31:52 +09:00
eomsangdon 0326c7c33d Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-29 20:31:09 +09:00
eomsangdonandClaude Opus 5.5 8793b4f6b2 feat(D03): 노선분석 바탕 MapLibre 전체 적용 — 겹 스물 · 등고 글 · 지명 GL · 처음 = 켬 · WebGL 못 쓰면 옛 캔버스 (PLAN 586-2 ①②③⑤ · 요구 448)
- GL 겹 스물: 위성 조각 · 음영 · 급경사 · 산사태위험(image) · 산림입지 · 보호구역 · 띠 · 물 면 · 건물(fill + 테두리)
  · 지적 · 등고선 · 도로 · 임도 짐작 · 올린 임도망도 · 세류 · 단층 · 지질구조선 · 0차곡 · 산사태취약 필지(line · 점선 px 그대로)
  · 산사태취약 점 · 지명 점(circle) — 색 · 굵기 · 투명도 · 차례 = 옛 캔버스 그대로
- 음영 = 위성 위 「곱하기 0.75」와 같은 셈의 검은 반투명 그림(밝기 g → 투명도 0.75·(1−g)) · 위성 끔 = 그대로
- 등고 수치 · 지명 = symbol(겹치면 뺌 · 등고 글은 선 따라) · 글꼴 = 화면 Noto Sans KR(OFL · 자체 보관)을 그 자리에서 그림
  (꼴에 글꼴 주소 없음 = 글꼴 조각 파일 없음 · 받기 전에 그 글자 글꼴 미리 받음)
- 카메라 주인 = D03 화면 view · 돌림 하나 — 휠 · 가운데 끌기 · 돌림 · 맞춤 · 단축키 모두 draw() → glCamera
- 처음 = GL(aislo:pref:d03.canvas = "1" 이면 옛 캔버스) · 겹 단추줄 끝 단추로 바꿈 · WebGL 못 열거나 잃음 = 저절로 옛 캔버스 + 알림
- GL 이면 캔버스 바탕 겹은 쉼(등고 주곡선 간격 셈만) · GL 이면 위성 한 장 그림 안 받음
- 산사태위험 그림도 경위도 귀퉁이(lonlat_corners 한 곳) · 묶음 꼴 표시 없는 합친 자료도 GL 이 받게 다시 쌈
- 캔버스 그리기 목록 = 새 D03_PlanAnalysis_UI_LayerPaint.ts(700줄 규칙) · 검증 창구 layers().gl = GL 겹별 그린 지물 수
- 잼(headless Edge 실 GPU · 검증_sub2_D · 겹 18 켬 = 자료 있는 겹 전부): 줌아웃 화면 간격 중앙 58.4 → 4.2 ms · 끊김 27 → 0/30
  · 끌기 62.5 → 4.2 ms · 끊김 37 → 0/40 · 마우스 4.2 ms 끊김 0 그대로 · 그은 선 대 GL 0.019 px(돌림 30° 0.019 · 확대 0.077)
- 시험: 캔버스 그리기를 옮긴 자리로 지킴 시험 넷 고침(581-2 · 582 · 584-1 · 584-3 — 그리기 차례 · 색 값이 LayerPaint 로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:28:13 +09:00
eomsangdonandClaude Opus 5.5 ba18b0f96d 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>
2026-10-29 20:21:39 +09:00
25 changed files with 1256 additions and 443 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;
}
+9 -6
View File
@@ -264,8 +264,6 @@ def slope_layer(
project_root: Path, models_dir: Path, epsg: int, kind: str = "slope"
) -> dict[str, Any]:
"""급경사(`slope`) · 음영기복(`hillshade`) — 지문(격자 파일 · 단계 · 판) 같으면 저장본."""
from common_util.common_util_crs import tm_to_lonlat
npz = models_dir / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz"
stamp = file_stamp(npz)
if stamp is None:
@@ -286,14 +284,19 @@ def slope_layer(
half = cell / 2
box = (x[0] - half, y[0] - half, x[-1] + half, y[-1] + half)
value["box_m"] = [float(v) for v in box]
value["box"] = [
list(tm_to_lonlat(px, py, epsg))
for px, py in ((box[0], box[1]), (box[2], box[1]), (box[2], box[3]), (box[0], box[3]))
]
value["box"] = lonlat_corners(box, epsg)
write_derived(project_root, store_kind, key, value)
return value
def lonlat_corners(box: Any, epsg: int) -> list[list[float]]:
"""TM 상자 → 경위도 네 귀퉁이(왼쪽 아래부터 돎) — GL 바탕 그림 자리(586)."""
from common_util.common_util_crs import tm_to_lonlat
x0, y0, x1, y1 = (float(v) for v in box)
return [list(tm_to_lonlat(px, py, epsg)) for px, py in ((x0, y0), (x1, y0), (x1, y1), (x0, y1))]
# ── 보호 · 규제 구역 ─────────────────────────────────────
# (열쇠, 이름, 브이월드 WFS 겹 이름) — `_분석_440-C` 표 1 그대로
+2 -1
View File
@@ -214,7 +214,8 @@ async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str,
raise HTTPException(status_code=503, detail=str(error)) from error
except RuntimeError as error:
raise HTTPException(status_code=502, detail=str(error)) from error
return {"status": "success", "kind": kind, **value}
box_lonlat = layers.lonlat_corners(value["box_m"], epsg)
return {"status": "success", "kind": kind, **value, "box": box_lonlat}
if kind not in layers.RASTER_KINDS:
raise HTTPException(status_code=400, detail=f"없는 그림 겹: {kind}")
root, epsg, _ = await _layer_root(project_id)
@@ -1,7 +1,8 @@
/* =============================================================================
* D03_PlanAnalysis_UI_LayerDefs.ts
* D03 바탕 겹 정의(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3) — 겹 단추 묶음 · 처음 값 · 보호구역 · 토양형 색 ·
* 산사태위험지도 범례 · 선 겹(단층 · 지질구조선 · 0차곡) 꼴 · 켬끔 취향 읽기 · 면 겹 채우기 · 선 긋기 · 그림 겹 놓기.
* D03 바탕 겹 정의(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 586-2) — 겹 단추 묶음 · 처음 값 · 보호구역 · 토양형 색 ·
* 산사태위험지도 범례 · 선 겹(단층 · 지질구조선 · 0차곡) 꼴 · 켬끔 취향 읽기 · 면 겹 채우기 · 선 긋기 · 그림 겹 놓기 ·
* GL 바탕 자료(지물 색 붙이기 · 그림 귀퉁이 · 음영 그늘 그림).
* 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기 차례 · 범례).
* ========================================================================== */
@@ -11,11 +12,13 @@ import { readByKey } from "../A00_Common/b_page_state";
import {
drawPreparedLayer,
normalizedToScreen,
type GeoJsonCollection,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { PlanRaster } from "./D03_PlanAnalysis_Api";
import type { ScreenMap } from "./D03_PlanAnalysis_UI_Map";
import type { GlImage } from "./D03_PlanAnalysis_UI_MapGl";
export type PlanLayerKey =
| "satellite"
@@ -268,3 +271,41 @@ export function readPref(key: string): Record<PlanLayerKey, boolean> {
}
return on;
}
/** GL 자료 — 지물마다 그릴 색(`_color`) · 덧 속성을 붙인 사본. */
export function glTagged(
collection: GeoJsonCollection,
color: (props: Record<string, unknown>) => string,
extra: Record<string, unknown> = {},
): GeoJsonCollection {
return {
...collection,
features: (collection.features ?? []).map((feature) => ({
...feature,
properties: { ...feature.properties, ...extra, _color: color(feature.properties ?? {}) },
})),
};
}
/** GL 그림 자리 — 서버 경위도 귀퉁이(왼쪽 아래부터 돎) → 왼쪽 위부터 · 없으면 null. */
export function glImage(raster: PlanRaster, url = raster.image): GlImage | null {
const box = raster.box;
return box?.length === 4 ? { url, corners: [box[3], box[2], box[1], box[0]] } : null;
}
/** 음영 그늘 그림 — 위성 위 「곱하기 0.75」와 같은 검은 반투명(밝기 g → 투명도 0.75 · (1 − g)). */
export function shadeImage(image: HTMLImageElement): string {
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext("2d")!;
context.drawImage(image, 0, 0);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
const px = pixels.data;
for (let i = 0; i < px.length; i += 4) {
px[i + 3] = Math.round(0.75 * (255 - px[i]) * (px[i + 3] / 255));
px[i] = px[i + 1] = px[i + 2] = 0;
}
context.putImageData(pixels, 0, 0);
return canvas.toDataURL("image/png");
}
@@ -0,0 +1,258 @@
/* =============================================================================
* D03_PlanAnalysis_UI_LayerPaint.ts
* D03 바탕 겹 옛 캔버스 그리기(PLAN 569-2 · 581 · 584 · 586-2) — 그리기 차례 · 색 · 굵기 · 투명도.
* GL 바탕(586-2)이 같은 꼴을 MapLibre 로 그림(`D03_PlanAnalysis_UI_MapGl`) · 이것은 GL 을 못 쓰거나 끈 때.
* 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 켬끔 · GL 이면 쉼).
* ========================================================================== */
import { themeColor } from "@ui/ui_template_palette";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
createNormalizer,
drawPreparedLabels,
lonLatToScreen,
pickLevelStep,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import {
LINE_KEYS,
LINE_LAYERS,
ZONE_COLORS,
drawRaster,
fillLayer,
stroke,
type PlanLayerKey,
} from "./D03_PlanAnalysis_UI_LayerDefs";
import type { Prepared } from "./D03_PlanAnalysis_UI_LayerLegend";
import type { PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
/** 바탕 겹(아래 → 위) — 켬끔 · 받은 자료 · 위성 틀 · 위성 그림(음영 곱하기)을 그때그때 읽음. */
export function createCanvasLayers(opts: {
on: Record<PlanLayerKey, boolean>;
data: Partial<Prepared>;
meta: () => VWorldMeta | null;
satellite: () => HTMLImageElement | null;
}): PlanMapLayer[] {
const { on, data } = opts;
/** 등고 글 폭 기억(글꼴 하나 · 582). */
const labelWidths = new Map<string, number>();
return [
{
base: true,
draw: (context, map) => {
if (!on.hillshade || !data.hillshade) return;
// 위성 위 = 곱하기(밝은 곳은 그대로 · 그늘만 짙게) · 위성 없으면 그대로
if (on.satellite && opts.satellite()) {
context.globalCompositeOperation = "multiply";
context.globalAlpha = 0.75;
}
drawRaster(context, map, data.hillshade);
},
},
{
base: true,
draw: (context, map) => {
if (!on.slope || !data.slope) return;
context.globalAlpha = 0.55;
drawRaster(context, map, data.slope);
},
},
{
base: true,
draw: (context, map) => {
if (!on.landslide || !data.landslide) return;
context.globalAlpha = 0.6;
drawRaster(context, map, data.landslide);
},
},
{
base: true,
draw: (context, map) => {
if (!on.soils || !data.soils) return;
for (const { layer, color } of data.soils.kinds) {
const fill = themeColor(color, "#facc15");
fillLayer(context, layer, map.view, fill, 0.35, { color: fill, width: 1.4 });
}
},
},
{
base: true,
draw: (context, map) => {
if (!on.zones || !data.zones) return;
for (const [kind, layer] of data.zones.kinds) {
const color = themeColor(ZONE_COLORS[kind] ?? "--draw-warning", "#eab308");
fillLayer(context, layer, map.view, color, 0.22, { color, width: 1.6 });
}
},
},
{
base: true,
draw: (context, map) => {
if (!on.bands || !data.bands) return;
const road = themeColor("--draw-map-band-road", "#facc15");
const stream = themeColor("--draw-map-band-stream", "#22d3ee");
fillLayer(context, data.bands.road, map.view, road, 0.16, {
color: road,
width: 1.2,
dash: [6, 4],
});
fillLayer(context, data.bands.stream, map.view, stream, 0.18, {
color: stream,
width: 1.2,
dash: [6, 4],
});
},
},
{
base: true,
draw: (context, map) => {
if (!on.cadastral || !data.cadastral) return;
context.globalAlpha = 0.8;
stroke(context, map, data.cadastral, themeColor("--draw-map-parcel", "#f0abfc"), 0.8);
},
},
{
base: true,
draw: (context, map) => {
if (!on.water || !data.water) return;
const color = themeColor("--draw-map-water", "#0ea5e9");
fillLayer(context, data.water, map.view, color, 0.45, { color, width: 1 });
},
},
{
base: true,
draw: (context, map) => {
if (!on.building || !data.building) return;
const color = themeColor("--draw-map-building", "#fb7185");
fillLayer(context, data.building, map.view, color, 0.7, {
color: "rgba(0, 0, 0, 0.55)",
width: 0.6,
});
},
},
{
base: true,
draw: (context, map) => {
const contour = on.contour && data.contour;
if (!contour) return;
const color = themeColor("--draw-map-contour", "#ffd68c");
const step = pickLevelStep(contour, map.view, 5);
context.globalAlpha = 0.7;
stroke(context, map, contour, color, 0.8, step);
stroke(context, map, contour, color, 1.6, 25); // 계곡선
context.globalAlpha = 1;
context.font = "10px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
drawPreparedLabels(
context,
contour,
map.view,
color,
25,
map.uprightRad,
"rgba(0, 0, 0, 0.6)",
labelWidths,
);
},
},
{
base: true,
draw: (context, map) => {
if (!on.road || !data.road) return;
stroke(context, map, data.road.all, "rgba(0, 0, 0, 0.55)", 3.2);
stroke(context, map, data.road.all, themeColor("--draw-map-road", "#f8fafc"), 1.6);
},
},
{
base: true,
draw: (context, map) => {
if (!on.forestRoad || !data.road) return;
context.setLineDash([7, 4]);
stroke(context, map, data.road.small, "rgba(0, 0, 0, 0.55)", 3.6);
stroke(context, map, data.road.small, themeColor("--draw-map-forest-road", "#f59e0b"), 2);
context.setLineDash([]);
},
},
{
base: true,
draw: (context, map) => {
const got = on.forestRoadFile && data.forestRoadFile;
if (!got) return;
stroke(context, map, got.layer, "rgba(0, 0, 0, 0.55)", 4);
stroke(context, map, got.layer, themeColor("--draw-map-forest-road-file", "#ea580c"), 2.4);
},
},
{
base: true,
draw: (context, map) => {
if (!on.stream || !data.stream) return;
const color = themeColor("--draw-map-stream", "#38bdf8");
stroke(context, map, data.stream[0], color, 1);
stroke(context, map, data.stream[1], color, 2.4);
},
},
...LINE_KEYS.map((key): PlanMapLayer => ({
base: true,
draw: (context, map) => {
const got = on[key] && data[key];
if (!got) return;
const { color, width, dash } = LINE_LAYERS[key];
context.setLineDash(dash);
stroke(context, map, got.layer, "rgba(0, 0, 0, 0.55)", width + 1.6);
stroke(context, map, got.layer, themeColor(color, "#f43f5e"), width);
context.setLineDash([]);
},
})),
{
base: true,
draw: (context, map) => {
const got = on.landslideZones && data.landslideZones;
const meta = opts.meta();
if (!got || !meta) return;
const color = themeColor("--draw-map-landslide-zone", "#e11d48");
// 필지 = 「이 필지 안 어딘가」 테두리만(지정 넓이 ≪ 필지 · 칠하지 않음)
context.setLineDash([6, 3]);
stroke(context, map, got.parcels, "rgba(255, 255, 255, 0.7)", 3.2);
stroke(context, map, got.parcels, color, 1.8);
context.setLineDash([]);
const normalizer = createNormalizer(meta);
context.fillStyle = color;
context.strokeStyle = "#ffffff";
context.lineWidth = 1.5;
for (const point of got.info.points) {
const [x, y] = lonLatToScreen(normalizer, map.view, point.lon, point.lat);
if (x < -10 || y < -10 || x > map.view.width + 10 || y > map.view.height + 10) continue;
context.beginPath();
context.arc(x, y, 5, 0, Math.PI * 2);
context.fill();
context.stroke();
}
},
},
{
base: true,
draw: (context, map) => {
const meta = opts.meta();
if (!on.place || !data.place || !meta) return;
const normalizer = createNormalizer(meta);
context.font = "600 11px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "bottom";
context.lineWidth = 3;
context.strokeStyle = "rgba(0, 0, 0, 0.7)";
context.fillStyle = themeColor("--draw-map-place", "#f8fafc");
for (const place of data.place) {
const [x, y] = lonLatToScreen(normalizer, map.view, place.lon, place.lat);
if (x < -60 || y < -20 || x > map.view.width + 60 || y > map.view.height + 20) continue;
map.upright(context, x, y, () => {
context.beginPath();
context.arc(x, y, 2.5, 0, Math.PI * 2);
context.fill();
context.strokeText(place.name, x, y - 4);
context.fillText(place.name, x, y - 4);
});
}
},
},
];
}
+129 -250
View File
@@ -15,8 +15,9 @@
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`).
* GL 바탕 시험(586-1 · 끝 단추 · `aislo:pref:d03.gl` · 처음 = 끔) — 위성 · 급경사 · 지적 · 등고선 · 도로 · 세류를
* MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스는 그 겹을 건너뜀(등고 글은 캔버스).
* GL 바탕(586-2 · 처음 = 켬 · 끝 단추 끔 = 옛 캔버스 · `aislo:pref:d03.canvas`) — 겹 스물 · 등고 글 · 지명을
* MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스 바탕 겹은 쉼(등고 간격 셈만) · 받기 · 범례는 같음 ·
* WebGL 못 씀 · 잃음 = 저절로 옛 캔버스 + 알림(취향은 안 바꿈).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
@@ -34,8 +35,6 @@ import {
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
createNormalizer,
drawPreparedLabels,
lonLatToScreen,
pickLevelStep,
type GeoJsonCollection,
type PreparedLayer,
@@ -61,25 +60,29 @@ import {
FOREST_ROAD_KINDS,
GROUPS,
LAYERS,
LINE_KEYS,
LINE_LAYERS,
SOIL_COLORS,
WATER_KEYS,
ZONE_COLORS,
drawRaster,
fillLayer,
glImage,
glTagged,
loadImage,
readPref,
stroke,
shadeImage,
type PlanLayerKey,
} from "./D03_PlanAnalysis_UI_LayerDefs";
import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend";
import { createGlBase, GL_KEYS } from "./D03_PlanAnalysis_UI_MapGl";
import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint";
import { createGlBase, GL_FONT, type GlImage } from "./D03_PlanAnalysis_UI_MapGl";
import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
const PREF_KEY = "aislo:pref:d03.layers";
const GL_PREF_KEY = "aislo:pref:d03.gl";
/** GL 글꼴 미리 받음 — 받기 전에 그린 글자는 대체 글꼴로 굳음(못 받아도 그림). */
const glFont = (weight: string, text: string): Promise<unknown> =>
document.fonts.load(`${weight} 24px "${GL_FONT}"`, text || "가").catch(() => null);
/** 옛 캔버스로 그림("1") — 처음 = GL. */
const CANVAS_PREF_KEY = "aislo:pref:d03.canvas";
/** 등고선을 받아 올 그은 선 둘레(m). */
const CONTOUR_PAD_M = 600;
type DataKey = keyof Prepared;
@@ -100,6 +103,8 @@ export interface PlanLayers {
on: Record<PlanLayerKey, boolean>;
loaded: PlanLayerKey[];
counts: Record<string, number>;
/** GL 겹마다 화면에 그린 지물 수(보임만 · GL 끔 = 빈 것). */
gl: Record<string, number>;
};
}
@@ -116,9 +121,14 @@ export function createPlanLayers(opts: {
let uploading = false;
let meta: VWorldMeta | null = null;
let satellite: HTMLImageElement | null = null;
const gl = createGlBase(projectId);
let glOn = readByKey(GL_PREF_KEY) === "1";
map.setGl(glOn ? gl : null);
/** 음영 GL 그림 — 그대로 · 위성 위 그늘(「곱하기」 꼴). */
let hillshade: { plain: GlImage; shade: GlImage } | null = null;
const gl = createGlBase(projectId, (why) => {
if (!glOn) return;
showToast(`그래픽 카드 그림을 못 써 옛 그림으로 — ${why}`, "info");
useGl(false);
});
let glOn = readByKey(CANVAS_PREF_KEY) !== "1";
const buttons = new Map<PlanLayerKey, HTMLButtonElement>();
const groups = GROUPS.map((group) =>
@@ -150,18 +160,24 @@ export function createPlanLayers(opts: {
roadGroup.splice(roadGroup.indexOf(buttons.get("forestRoadFile")!) + 1, 0, upload);
const glButton = createIconButton({
icon: "gauge",
title:
"GL 바탕 시험(위성 조각 · 급경사 · 지적 · 등고선 · 도로 · 세류를 그래픽 카드로 · 끔 = 지금 그림)",
title: "그래픽 카드 그림(바탕 겹 · 글을 GPU 로 · 끔 = 옛 캔버스 그림)",
size: 32,
pressed: glOn,
onClick: () => {
glOn = !glOn;
writeByKey(GL_PREF_KEY, glOn ? "1" : "0");
glButton.setAttribute("aria-pressed", String(glOn));
map.setGl(glOn ? gl : null);
writeByKey(CANVAS_PREF_KEY, glOn ? "1" : "0");
useGl(!glOn);
},
});
glButton.dataset.layer = "gl";
/** GL ↔ 옛 캔버스 — 못 켜면 캔버스(알림은 `onFail`). */
function useGl(want: boolean): void {
glOn = want && map.setGl(gl);
if (!glOn) map.setGl(null);
glButton.setAttribute("aria-pressed", String(glOn));
void ensure("satellite");
showSatellite();
map.redrawBase();
}
groups.push([glButton]);
const tools = createMapTools({ label: "지도 겹", groups });
const bar = tools.root;
@@ -174,8 +190,9 @@ export function createPlanLayers(opts: {
legend.className = "d03-plan__legend";
legend.title = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이";
/** 받음 — 위성은 GL 이면 조각(한 장 그림 안 받음). */
const loadedOf = (key: PlanLayerKey): boolean =>
key === "satellite" ? !!satellite : !!data[dataKey(key)];
key === "satellite" ? glOn || !!satellite : !!data[dataKey(key)];
function sync(): void {
for (const def of LAYERS) {
@@ -187,13 +204,15 @@ export function createPlanLayers(opts: {
button.setAttribute("aria-label", title);
}
upload.disabled = !meta || uploading;
for (const key of GL_KEYS) gl.setVisible(key, on[key]);
for (const def of LAYERS) gl.setVisible(def.key, on[def.key]);
gl.setData("hillshade", hillshade && (on.satellite ? hillshade.shade : hillshade.plain));
const rows = meta ? legendRows(on, data) : [];
legend.replaceChildren(...rows.map((row) => createLegend(row)));
legend.hidden = !rows.length;
}
sync();
useGl(glOn);
function toggle(key: PlanLayerKey): void {
on[key] = !on[key];
@@ -246,6 +265,7 @@ export function createPlanLayers(opts: {
layer: prepareLayer(info.collection, createNormalizer(at)),
info,
};
gl.setData("forestRoadFile", info.collection);
on.forestRoadFile = true;
writeByKey(PREF_KEY, JSON.stringify(on));
showToast(
@@ -283,6 +303,7 @@ export function createPlanLayers(opts: {
features: (collection.features ?? []).filter((feature) => test(feature.properties ?? {})),
});
if (key === "satellite") {
if (glOn) return; // GL = 조각
// 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀
const v = `${at.x_min}_${at.x_max}_${at.y_min}_${at.y_max}`;
satellite = await loadImage(`${getVWorldMapUrl(projectId, "satellite")}&v=${v}`);
@@ -296,12 +317,10 @@ export function createPlanLayers(opts: {
Math.max(...xs) + CONTOUR_PAD_M,
Math.max(...ys) + CONTOUR_PAD_M,
].map((v) => v.toFixed(0));
const collection = await fetchCachedSheetLayer<GeoJsonCollection>(
projectId,
"도엽_등고선",
"route",
bbox.join(","),
);
const [collection] = await Promise.all([
fetchCachedSheetLayer<GeoJsonCollection>(projectId, "도엽_등고선", "route", bbox.join(",")),
glFont("400", "0123456789-"),
]);
data.contour = indexLayerLod(prepare(collection, ["등고수치"]));
gl.setData("contour", collection);
} else if (key === "stream") {
@@ -330,30 +349,52 @@ export function createPlanLayers(opts: {
} else if (key === "forestRoadFile") {
const info = await fetchPlanForestRoads(projectId);
data.forestRoadFile = { layer: prepare(info.collection), info };
gl.setData("forestRoadFile", info.collection);
} else if (key === "landslideZones") {
const info = await fetchPlanLandslideZones(projectId);
data.landslideZones = { parcels: prepare(info.parcels), info };
gl.setData("landslideZones", {
features: [
...(info.parcels.features ?? []),
...info.points.map((point) => ({
type: "Feature",
properties: {},
geometry: { type: "Point", coordinates: [point.lon, point.lat] },
})),
],
} as GeoJsonCollection);
if (info.missing.length)
showToast(`산사태취약지역 — ${info.missing.join(" · ")} 자료 없음`, "info");
} else if (key === "slope" || key === "hillshade" || key === "landslide") {
const raster = await fetchPlanRaster(projectId, key);
data[key] = { raster, image: await loadImage(raster.image) };
const box = raster.box; // 경위도 귀퉁이(왼쪽 아래부터 돎)
if (key === "slope" && box?.length === 4)
gl.setData("slope", { url: raster.image, corners: [box[3], box[2], box[1], box[0]] });
const image = await loadImage(raster.image);
data[key] = { raster, image };
const plain = glImage(raster);
if (key !== "hillshade") gl.setData(key, plain);
else if (plain) hillshade = { plain, shade: glImage(raster, shadeImage(image))! };
} else if (key === "water") {
const parts = await Promise.all(
WATER_KEYS.map((name) => fetchPlanSheetLayer(projectId, name)),
);
data.water = prepare({ features: parts.flatMap((part) => part.features ?? []) });
const water = { features: parts.flatMap((part) => part.features ?? []) };
data.water = prepare(water);
gl.setData("water", water);
} else if (key === "building") {
data.building = prepare(await fetchPlanSheetLayer(projectId, "건물"));
const building = await fetchPlanSheetLayer(projectId, "건물");
data.building = prepare(building);
gl.setData("building", building);
} else if (key === "zones") {
const zones = await fetchPlanZones(projectId);
const kinds = new Map<string, PreparedLayer>();
for (const kind of Object.keys(zones.summary))
kinds.set(kind, prepare(pick(zones, (props) => props["kind"] === kind)));
data.zones = { kinds, summary: zones.summary };
gl.setData(
"zones",
glTagged(zones, (props) =>
themeColor(ZONE_COLORS[String(props["kind"])] ?? "--draw-warning", "#eab308"),
),
);
const failed = Object.values(zones.summary).filter((kind) => kind.failed);
if (failed.length)
showToast(
@@ -369,6 +410,15 @@ export function createPlanLayers(opts: {
})),
summary: soils.summary,
};
const colorOf = new Map(
soils.summary.map((soil, i) => [soil.code, data.soils!.kinds[i].color]),
);
gl.setData(
"soils",
glTagged(soils, (props) =>
themeColor(colorOf.get(props["code"] as never) ?? "", "#facc15"),
),
);
} else if (key === "faults" || key === "lineaments" || key === "zeroOrder") {
const kind = LINE_LAYERS[key].kind;
// 0차곡 첫 셈 = 몇 초 — 늦으면 알림
@@ -388,6 +438,7 @@ export function createPlanLayers(opts: {
count: got.count,
length_m: got.length_m,
};
gl.setData(key, got.collection);
} finally {
window.clearTimeout(slow);
}
@@ -398,6 +449,16 @@ export function createPlanLayers(opts: {
road: prepare(info.road.collection),
info,
};
const band = (name: "stream" | "road", fallback: string): GeoJsonCollection =>
glTagged(info[name].collection, () => themeColor(`--draw-map-band-${name}`, fallback), {
_band: name,
});
gl.setData("bands", {
features: [
...(band("road", "#facc15").features ?? []),
...(band("stream", "#22d3ee").features ?? []),
],
});
} else {
const collection = await fetchPlanSheetLayer(projectId, "지명");
data.place = (collection.features ?? []).flatMap((feature) => {
@@ -407,229 +468,35 @@ export function createPlanLayers(opts: {
const [lon, lat] = geometry.coordinates as number[];
return [{ lon, lat, name }];
});
await glFont("600", data.place.map((place) => place.name).join(""));
gl.setData("place", {
features: data.place.map(({ lon, lat, name }) => ({
type: "Feature",
properties: { name },
geometry: { type: "Point", coordinates: [lon, lat] },
})),
} as GeoJsonCollection);
}
}
/** 등고 글 폭 기억(글꼴 하나 · 582). */
const labelWidths = new Map<string, number>();
const canvasLayers = createCanvasLayers({
on,
data,
meta: () => meta,
satellite: () => satellite,
});
/** GL 이면 캔버스 바탕 겹은 쉼 — 등고 주곡선 간격(화면 안 가닥 수)만 셈. */
const layers: PlanMapLayer[] = [
{
base: true,
draw: (context, map) => {
if (!on.hillshade || !data.hillshade) return;
// 위성 위 = 곱하기(밝은 곳은 그대로 · 그늘만 짙게) · 위성 없으면 그대로
if (on.satellite && satellite) {
context.globalCompositeOperation = "multiply";
context.globalAlpha = 0.75;
}
drawRaster(context, map, data.hillshade);
},
},
{
base: true,
draw: (context, map) => {
if (!on.slope || !data.slope || glOn) return;
context.globalAlpha = 0.55;
drawRaster(context, map, data.slope);
},
},
{
base: true,
draw: (context, map) => {
if (!on.landslide || !data.landslide) return;
context.globalAlpha = 0.6;
drawRaster(context, map, data.landslide);
},
},
{
base: true,
draw: (context, map) => {
if (!on.soils || !data.soils) return;
for (const { layer, color } of data.soils.kinds) {
const fill = themeColor(color, "#facc15");
fillLayer(context, layer, map.view, fill, 0.35, { color: fill, width: 1.4 });
}
},
},
{
base: true,
draw: (context, map) => {
if (!on.zones || !data.zones) return;
for (const [kind, layer] of data.zones.kinds) {
const color = themeColor(ZONE_COLORS[kind] ?? "--draw-warning", "#eab308");
fillLayer(context, layer, map.view, color, 0.22, { color, width: 1.6 });
}
},
},
{
base: true,
draw: (context, map) => {
if (!on.bands || !data.bands) return;
const road = themeColor("--draw-map-band-road", "#facc15");
const stream = themeColor("--draw-map-band-stream", "#22d3ee");
fillLayer(context, data.bands.road, map.view, road, 0.16, {
color: road,
width: 1.2,
dash: [6, 4],
});
fillLayer(context, data.bands.stream, map.view, stream, 0.18, {
color: stream,
width: 1.2,
dash: [6, 4],
});
},
},
{
base: true,
draw: (context, map) => {
if (!on.cadastral || !data.cadastral || glOn) return;
context.globalAlpha = 0.8;
stroke(context, map, data.cadastral, themeColor("--draw-map-parcel", "#f0abfc"), 0.8);
},
},
{
base: true,
draw: (context, map) => {
if (!on.water || !data.water) return;
const color = themeColor("--draw-map-water", "#0ea5e9");
fillLayer(context, data.water, map.view, color, 0.45, { color, width: 1 });
},
},
{
base: true,
draw: (context, map) => {
if (!on.building || !data.building) return;
const color = themeColor("--draw-map-building", "#fb7185");
fillLayer(context, data.building, map.view, color, 0.7, {
color: "rgba(0, 0, 0, 0.55)",
width: 0.6,
});
},
},
{
base: true,
draw: (context, map) => {
const contour = on.contour && data.contour;
if (!contour) return;
const color = themeColor("--draw-map-contour", "#ffd68c");
const step = pickLevelStep(contour, map.view, 5);
if (glOn) gl.setContourStep(step); // 선 = GL · 글 = 캔버스
if (!glOn) {
context.globalAlpha = 0.7;
stroke(context, map, contour, color, 0.8, step);
stroke(context, map, contour, color, 1.6, 25); // 계곡선
}
context.globalAlpha = 1;
context.font = "10px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
drawPreparedLabels(
context,
contour,
map.view,
color,
25,
map.uprightRad,
"rgba(0, 0, 0, 0.6)",
labelWidths,
);
},
},
{
base: true,
draw: (context, map) => {
if (!on.road || !data.road || glOn) return;
stroke(context, map, data.road.all, "rgba(0, 0, 0, 0.55)", 3.2);
stroke(context, map, data.road.all, themeColor("--draw-map-road", "#f8fafc"), 1.6);
},
},
{
base: true,
draw: (context, map) => {
if (!on.forestRoad || !data.road) return;
context.setLineDash([7, 4]);
stroke(context, map, data.road.small, "rgba(0, 0, 0, 0.55)", 3.6);
stroke(context, map, data.road.small, themeColor("--draw-map-forest-road", "#f59e0b"), 2);
context.setLineDash([]);
},
},
{
base: true,
draw: (context, map) => {
const got = on.forestRoadFile && data.forestRoadFile;
if (!got) return;
stroke(context, map, got.layer, "rgba(0, 0, 0, 0.55)", 4);
stroke(context, map, got.layer, themeColor("--draw-map-forest-road-file", "#ea580c"), 2.4);
},
},
{
base: true,
draw: (context, map) => {
if (!on.stream || !data.stream || glOn) return;
const color = themeColor("--draw-map-stream", "#38bdf8");
stroke(context, map, data.stream[0], color, 1);
stroke(context, map, data.stream[1], color, 2.4);
},
},
...LINE_KEYS.map((key): PlanMapLayer => ({
base: true,
draw: (context, map) => {
const got = on[key] && data[key];
if (!got) return;
const { color, width, dash } = LINE_LAYERS[key];
context.setLineDash(dash);
stroke(context, map, got.layer, "rgba(0, 0, 0, 0.55)", width + 1.6);
stroke(context, map, got.layer, themeColor(color, "#f43f5e"), width);
context.setLineDash([]);
},
...canvasLayers.map((layer): PlanMapLayer => ({
...layer,
draw: (c, m) => void (glOn || layer.draw(c, m)),
})),
{
base: true,
draw: (context, map) => {
const got = on.landslideZones && data.landslideZones;
if (!got || !meta) return;
const color = themeColor("--draw-map-landslide-zone", "#e11d48");
// 필지 = 「이 필지 안 어딘가」 테두리만(지정 넓이 ≪ 필지 · 칠하지 않음)
context.setLineDash([6, 3]);
stroke(context, map, got.parcels, "rgba(255, 255, 255, 0.7)", 3.2);
stroke(context, map, got.parcels, color, 1.8);
context.setLineDash([]);
const normalizer = createNormalizer(meta);
context.fillStyle = color;
context.strokeStyle = "#ffffff";
context.lineWidth = 1.5;
for (const point of got.info.points) {
const [x, y] = lonLatToScreen(normalizer, map.view, point.lon, point.lat);
if (x < -10 || y < -10 || x > map.view.width + 10 || y > map.view.height + 10) continue;
context.beginPath();
context.arc(x, y, 5, 0, Math.PI * 2);
context.fill();
context.stroke();
}
},
},
{
base: true,
draw: (context, map) => {
if (!on.place || !data.place || !meta) return;
const normalizer = createNormalizer(meta);
context.font = "600 11px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "bottom";
context.lineWidth = 3;
context.strokeStyle = "rgba(0, 0, 0, 0.7)";
context.fillStyle = themeColor("--draw-map-place", "#f8fafc");
for (const place of data.place) {
const [x, y] = lonLatToScreen(normalizer, map.view, place.lon, place.lat);
if (x < -60 || y < -20 || x > map.view.width + 60 || y > map.view.height + 20) continue;
map.upright(context, x, y, () => {
context.beginPath();
context.arc(x, y, 2.5, 0, Math.PI * 2);
context.fill();
context.strokeText(place.name, x, y - 4);
context.fillText(place.name, x, y - 4);
});
}
draw: (_, m) => {
if (glOn && on.contour && data.contour)
gl.setContourStep(pickLevelStep(data.contour, m.view, 5));
},
},
];
@@ -643,7 +510,9 @@ export function createPlanLayers(opts: {
if (changed) {
satellite = null;
for (const key of Object.keys(data)) delete data[key as DataKey];
for (const key of GL_KEYS) if (key !== "satellite") gl.setData(key, null);
hillshade = null;
for (const def of LAYERS)
if (def.key !== "satellite" && def.key !== "forestRoad") gl.setData(def.key, null);
}
map.update({ meta });
showSatellite();
@@ -660,6 +529,16 @@ export function createPlanLayers(opts: {
on: { ...on },
loaded: LAYERS.map((def) => def.key).filter(loadedOf),
counts: layerCounts(data),
gl: Object.fromEntries(
(gl.map()?.getStyle().layers ?? [])
.filter((layer) => layer.layout?.visibility !== "none")
.map((layer) => [
layer.id,
layer.type === "raster"
? 1
: gl.map()!.queryRenderedFeatures({ layers: [layer.id] }).length,
]),
),
}),
};
}
+9 -6
View File
@@ -11,7 +11,9 @@
* 캔버스 두 장(582) — 아래 = 위성 · 바탕 겹(`base`) · 위 = 그은 선 · 재기 · 누름 받음.
* 마우스 움직임 = 위만 다시 그림 · 화면이 바뀌면(옮김 · 확대 · 돌림 · 겹) 둘 다 · 한 화면 한 번(rAF).
* TM 겹(그은 선 · 재기 · 그림 겹)은 경위도로 바꿔 바탕과 같은 자리(586-0 · `localTmFrame`) — 좌표계를 모르면 옛 꼴.
* GL 바탕 시험(586-1 · `setGl`) — 맨 아래 MapLibre 가 이 화면을 따라감 · 캔버스 겹은 그 투영으로 그림.
* GL 바탕(586 · `setGl`) — 맨 아래 MapLibre 가 이 화면을 따라감 · 캔버스 겹은 그 투영으로 그림.
* 카메라 주인 = 이 화면 `view` · 돌림 하나 — 휠 · 가운데 끌기 · 돌림 단추 · 맞춤 · 앞으로 붙을 팬 단추 · 단축키는
* 모두 `view` 를 바꾸고 `draw()` → 한 화면 한 번 `glCamera` 로 MapLibre 를 맞춘 뒤 캔버스를 그림.
* ========================================================================== */
import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools";
@@ -127,8 +129,8 @@ export interface PlanMap {
redraw: () => void;
/** 바탕 겹까지 다시 그림(겹 켬끔 · 받음). */
redrawBase: () => void;
/** GL 바탕 켬(MapLibre 를 맨 아래에) · null = 끔(지금 캔버스). */
setGl: (gl: GlBase | null) => void;
/** GL 바탕 켬(MapLibre 를 맨 아래에 · 참 = 섬 · 거짓 = WebGL 못 씀) · null = 끔(옛 캔버스). */
setGl: (gl: GlBase | null) => boolean;
/** 맞출 점이 들어오게 다시 맞춤. */
fit: () => void;
/** 검증 창구 — 작업 좌표계 점 → 화면에 보이는 px(돌린 뒤 · 그리기 전이면 null). */
@@ -526,12 +528,13 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
redraw: drawTop,
redrawBase: draw,
setGl(next) {
if (next === gl) return;
if (next === gl) return !!gl;
gl?.enable(null);
gl = next;
gl?.enable(root);
gl = null;
if (next?.enable(root)) gl = next;
gl?.resize();
draw();
return !!gl;
},
fit() {
refit();
+310 -93
View File
@@ -1,14 +1,20 @@
/* =============================================================================
* D03_PlanAnalysis_UI_MapGl.ts
* D03 GL 바탕 시험(PLAN 586-1 · 요구 445 B) — 바탕 겹 여섯을 MapLibre(WebGL · BSD-3)로:
* 위성 조각(서버 대리 z6~19) · 급경사 그림 · 지적 · 등고선(주곡선 · 계곡선 25 m) · 도로 · 세류 · 하천.
* 그 밖 겹 · 등고 글 · 그은 선 · 재기 · 누름 = 지금 캔버스 그대로(위에 겹침).
* 카메라 = D03 화면(`ViewState` · 돌림)을 따라감 · 조작은 안 받음(누름은 위 캔버스).
* 자료 = 지금 받는 GeoJSON 그대로(조각내기는 MapLibre 일꾼 스레드) · 지도가 없어도 자료는 담아 둠.
* D03 GL 바탕(PLAN 586-1 · 586-2 · 요구 445 B · 448) — 바탕 겹 스물을 MapLibre(WebGL · BSD-3)로:
* 위성 조각(서버 대리 · 서버 기억 z6~19) · 그림 겹(음영 · 급경사 · 산사태위험 = image) ·
* 면 겹(산림입지 · 보호구역 · 띠 · 물 면 · 건물 = fill) · 선 겹(지적 · 등고선 · 도로 · 임도 짐작 · 올린 임도망도 ·
* 세류 · 단층 · 지질구조선 · 0차곡 · 산사태취약 필지 = line · 점선 포함) · 점(산사태취약 · 지명 = circle) ·
* 글(등고 수치 · 지명 = symbol · 겹치면 뺌 · 글꼴 = 화면 Noto Sans KR(OFL · 자체 보관)을 그 자리에서 그림 —
* 꼴에 글꼴 주소가 없으면 MapLibre 가 모든 글자를 화면 글꼴로 그림 · 글꼴 조각 파일 없음).
* 그은 선 · 재기 · 고르기 · 누름 = 위 캔버스 그대로(위에 겹침).
* 카메라 주인 = D03 화면(`ViewState` · 돌림) 하나 — 여기는 따라가기만 · 조작은 안 받음.
* 자료 = 지금 받는 GeoJSON 그대로(조각내기는 MapLibre 일꾼 스레드) · 색 = 받는 쪽이 지물에 붙인 `_color`.
* 그래픽 카드 · WebGL 이 없거나 잃으면 `onFail` — 부르는 쪽이 옛 캔버스로.
* ========================================================================== */
import {
Map as MapLibreMap,
type ExpressionSpecification,
type FilterSpecification,
type LayerSpecification,
type SourceSpecification,
@@ -17,138 +23,334 @@ import "maplibre-gl/dist/maplibre-gl.css";
import { API_BASE_URL } from "@config/config_frontend";
import { themeColor } from "@ui/ui_template_palette";
import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { PlanLayerKey } from "./D03_PlanAnalysis_UI_LayerDefs";
/** GL 로 옮긴 겹. */
export type GlLayerKey = "satellite" | "slope" | "cadastral" | "contour" | "road" | "stream";
export const GL_KEYS: readonly GlLayerKey[] = [
"satellite",
"slope",
"cadastral",
"contour",
"road",
"stream",
];
/** 자료 소스(임도 짐작 = 도로 소스를 거름 · 위성 = 조각). */
export type GlSource = Exclude<PlanLayerKey, "satellite" | "forestRoad">;
/** 급경사 그림 — 주소 + 네 귀퉁이 경위도(왼쪽 위 · 오른쪽 위 · 오른쪽 아래 · 왼쪽 아래). */
/** 그림 겹 — 주소 + 네 귀퉁이 경위도(왼쪽 위 · 오른쪽 위 · 오른쪽 아래 · 왼쪽 아래). */
export interface GlImage {
url: string;
corners: [[number, number], [number, number], [number, number], [number, number]];
}
/** 글꼴 — 화면 `@font-face`(ui_template/fonts · OFL) 이름. 미리 받아 둬야 첫 글자가 대체 글꼴로 안 굳음. */
export const GL_FONT = "Noto Sans KR";
const GL_FONT_BOLD = [`${GL_FONT} SemiBold`, GL_FONT];
export interface GlBase {
/** 자료 담기(지도 없어도 · 켤 때 올림) — null = 비움. */
setData: (
key: Exclude<GlLayerKey, "satellite">,
data: GeoJsonCollection | GlImage | null,
) => void;
setVisible: (key: GlLayerKey, on: boolean) => void;
setData: (source: GlSource, data: GeoJsonCollection | GlImage | null) => void;
setVisible: (key: PlanLayerKey, on: boolean) => void;
/** 등고선 주곡선 간격(m) — 캔버스와 같은 「화면 안 가닥 수」 규칙. */
setContourStep: (step: number) => void;
/** 켬 = 지도를 `parent` 맨 앞에 만듦 · 끔 = 버림. */
enable: (parent: HTMLElement | null) => void;
/** 켬 = 지도를 `parent` 맨 앞에 만듦(참 = 섬 · 거짓 = WebGL 못 씀) · null = 버림. */
enable: (parent: HTMLElement | null) => boolean;
map: () => MapLibreMap | null;
/** 화면을 따라감(경위도 가운데 · 줌 · 돌림 도) — 같은 화면에 바로 그림. */
camera: (center: [number, number], zoom: number, bearingDeg: number) => void;
resize: () => void;
}
const HALO = "rgba(0, 0, 0, 0.55)";
type GlSpec = LayerSpecification & { key: PlanLayerKey; source: GlSource | "satellite" };
const LEVEL = ["to-number", ["get", "등고수치"], 0];
const HALO = "rgba(0, 0, 0, 0.55)";
const ROUND = { "line-join": "round", "line-cap": "round" } as const;
const COLOR: ExpressionSpecification = ["get", "_color"];
const LEVEL: ExpressionSpecification = ["to-number", ["get", "등고수치"], 0];
/** 주곡선 = 간격 배수(계곡선도 — 캔버스처럼 계곡선은 두 번 겹쳐 짙음). */
const minorFilter = (step: number): FilterSpecification =>
["==", ["%", LEVEL, step], 0] as unknown as FilterSpecification;
const INDEX = ["==", ["%", LEVEL, 25], 0] as unknown as FilterSpecification;
/** 캔버스 점선(px) → MapLibre 점선(선 굵기 배수). */
const dash = (px: number[], width: number): number[] => px.map((v) => v / width);
/** 겹 차례(아래 → 위)와 꼴 — 캔버스 `D03_PlanAnalysis_UI_Layers` 의 색 · 굵기 · 투명도 그대로. */
function layerSpecs(step: number): Array<LayerSpecification & { key: GlLayerKey }> {
const contour = themeColor("--draw-map-contour", "#ffd68c");
const road = themeColor("--draw-map-road", "#f8fafc");
const stream = themeColor("--draw-map-stream", "#38bdf8");
const parcel = themeColor("--draw-map-parcel", "#f0abfc");
const round = { "line-join": "round", "line-cap": "round" } as const;
/** 선 겹 — 검은 테두리 + 선(점선이면 둘 다 같은 px 무늬). */
function haloLine(
key: PlanLayerKey,
source: GlSource,
color: string,
width: number,
pattern: number[] = [],
halo = { color: HALO, width: width + 1.6 },
filter?: FilterSpecification,
): GlSpec[] {
const line = (id: string, paint: Record<string, unknown>, w: number): GlSpec =>
({
key,
id,
type: "line",
source,
...(filter ? { filter } : {}),
layout: ROUND,
paint: {
...paint,
"line-width": w,
...(pattern.length ? { "line-dasharray": dash(pattern, w) } : {}),
},
}) as GlSpec;
return [
line(`${key}-halo`, { "line-color": halo.color }, halo.width),
line(key, { "line-color": color }, width),
];
}
/** 면 겹 — 채움 + 테두리(캔버스 `fillLayer` 처럼 테두리 투명도 0.9). */
function fillEdge(
key: PlanLayerKey,
source: GlSource,
fill: string | ExpressionSpecification,
opacity: number | ExpressionSpecification,
edge: { color: string | ExpressionSpecification; width: number; dash?: number[] },
): GlSpec[] {
return [
{ key: "satellite", id: "satellite", type: "raster", source: "satellite" },
{
key: "slope",
id: "slope",
key,
id: key,
type: "fill",
source,
paint: { "fill-color": fill, "fill-opacity": opacity },
} as GlSpec,
{
key,
id: `${key}-edge`,
type: "line",
source,
layout: ROUND,
paint: {
"line-color": edge.color,
"line-width": edge.width,
"line-opacity": 0.9,
...(edge.dash ? { "line-dasharray": dash(edge.dash, edge.width) } : {}),
},
} as GlSpec,
];
}
/** 겹 차례(아래 → 위)와 꼴 — 캔버스 `D03_PlanAnalysis_UI_Layers` 의 색 · 굵기 · 투명도 · 차례 그대로. */
function layerSpecs(step: number): GlSpec[] {
const contour = themeColor("--draw-map-contour", "#ffd68c");
const place = themeColor("--draw-map-place", "#f8fafc");
const landslideZone = themeColor("--draw-map-landslide-zone", "#e11d48");
const raster = (key: GlSource | "satellite", opacity: number): GlSpec =>
({
key,
id: key,
type: "raster",
source: "slope",
paint: { "raster-opacity": 0.55, "raster-fade-duration": 0 },
},
source: key,
paint: { "raster-opacity": opacity, "raster-fade-duration": 0 },
}) as GlSpec;
const lineKind = (key: "faults" | "lineaments" | "zeroOrder", width: number, pattern: number[]) =>
haloLine(key, key, themeColor(LINE_COLOR[key], "#f43f5e"), width, pattern);
return [
raster("satellite", 1),
raster("hillshade", 1), // 위성 위 = 「곱하기」와 같은 검은 그늘 그림(받는 쪽이 바꿈)
raster("slope", 0.55),
raster("landslide", 0.6),
...fillEdge("soils", "soils", COLOR, 0.35, { color: COLOR, width: 1.4 }),
...fillEdge("zones", "zones", COLOR, 0.22, { color: COLOR, width: 1.6 }),
...fillEdge("bands", "bands", COLOR, ["case", ["==", ["get", "_band"], "road"], 0.16, 0.18], {
color: COLOR,
width: 1.2,
dash: [6, 4],
}),
{
key: "cadastral",
id: "cadastral",
type: "line",
source: "cadastral",
layout: round,
paint: { "line-color": parcel, "line-width": 0.8, "line-opacity": 0.8 },
},
layout: ROUND,
paint: {
"line-color": themeColor("--draw-map-parcel", "#f0abfc"),
"line-width": 0.8,
"line-opacity": 0.8,
},
} as GlSpec,
...((): GlSpec[] => {
const water = themeColor("--draw-map-water", "#0ea5e9");
return fillEdge("water", "water", water, 0.45, { color: water, width: 1 });
})(),
...fillEdge("building", "building", themeColor("--draw-map-building", "#fb7185"), 0.7, {
color: HALO,
width: 0.6,
}),
{
key: "contour",
id: "contour-minor",
type: "line",
source: "contour",
filter: minorFilter(step),
layout: round,
layout: ROUND,
paint: { "line-color": contour, "line-width": 0.8, "line-opacity": 0.7 },
},
} as GlSpec,
{
key: "contour",
id: "contour-index",
type: "line",
source: "contour",
filter: ["==", ["%", LEVEL, 25], 0],
layout: round,
filter: INDEX,
layout: ROUND,
paint: { "line-color": contour, "line-width": 1.6, "line-opacity": 0.7 },
},
} as GlSpec,
{
key: "road",
id: "road-halo",
type: "line",
source: "road",
layout: round,
paint: { "line-color": HALO, "line-width": 3.2 },
},
{
key: "road",
id: "road",
type: "line",
source: "road",
layout: round,
paint: { "line-color": road, "line-width": 1.6 },
},
key: "contour",
id: "contour-label",
type: "symbol",
source: "contour",
filter: INDEX,
layout: {
"symbol-placement": "line",
"symbol-spacing": 320,
"text-field": ["to-string", LEVEL],
"text-font": [GL_FONT],
"text-size": 10,
"text-max-angle": 30,
"text-padding": 2,
},
paint: {
"text-color": contour,
"text-halo-color": "rgba(0, 0, 0, 0.6)",
"text-halo-width": 1.5,
},
} as GlSpec,
...haloLine("road", "road", themeColor("--draw-map-road", "#f8fafc"), 1.6, [], {
color: HALO,
width: 3.2,
}),
...haloLine(
"forestRoad",
"road",
themeColor("--draw-map-forest-road", "#f59e0b"),
2,
[7, 4],
{ color: HALO, width: 3.6 },
[
"in",
["get", "도로구분"],
["literal", ["소로", "미분류"]],
] as unknown as FilterSpecification,
),
...haloLine(
"forestRoadFile",
"forestRoadFile",
themeColor("--draw-map-forest-road-file", "#ea580c"),
2.4,
[],
{ color: HALO, width: 4 },
),
{
key: "stream",
id: "stream",
type: "line",
source: "stream",
layout: round,
layout: ROUND,
paint: {
"line-color": stream,
"line-color": themeColor("--draw-map-stream", "#38bdf8"),
"line-width": ["case", ["==", ["get", "구분"], "세류"], 1, 2.4],
},
},
] as Array<LayerSpecification & { key: GlLayerKey }>;
} as GlSpec,
...lineKind("faults", 2.4, []),
...lineKind("lineaments", 1.8, [8, 4]),
...lineKind("zeroOrder", 1.8, [4, 3]),
// 산사태취약 — 필지 = 「이 필지 안 어딘가」 테두리만 · 점 = 지정 자리
...haloLine(
"landslideZones",
"landslideZones",
landslideZone,
1.8,
[6, 3],
{ color: "rgba(255, 255, 255, 0.7)", width: 3.2 },
["!=", ["geometry-type"], "Point"] as unknown as FilterSpecification,
),
{
key: "landslideZones",
id: "landslideZones-point",
type: "circle",
source: "landslideZones",
filter: ["==", ["geometry-type"], "Point"],
paint: {
"circle-radius": 5,
"circle-color": landslideZone,
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 1.5,
},
} as GlSpec,
{
key: "place",
id: "place-dot",
type: "circle",
source: "place",
paint: { "circle-radius": 2.5, "circle-color": place },
} as GlSpec,
{
key: "place",
id: "place",
type: "symbol",
source: "place",
layout: {
"text-field": ["get", "name"],
"text-font": GL_FONT_BOLD,
"text-size": 11,
"text-anchor": "bottom",
"text-offset": [0, -0.36],
"text-padding": 2,
},
paint: {
"text-color": place,
"text-halo-color": "rgba(0, 0, 0, 0.7)",
"text-halo-width": 1.5,
},
} as GlSpec,
];
}
/** 선 겹 색 값(캔버스 `LINE_LAYERS` 와 같음). */
const LINE_COLOR = {
faults: "--draw-map-fault",
lineaments: "--draw-map-lineament",
zeroOrder: "--draw-map-zero-order",
} as const;
/** 겹 색 지문 — 밝음 · 어두움이 바뀌면 색을 다시. */
const themeKey = (): string =>
["--draw-map-contour", "--draw-map-road", "--draw-map-stream", "--draw-map-parcel"]
.map((name) => themeColor(name, ""))
.join();
export function createGlBase(projectId: string): GlBase {
const data = new Map<GlLayerKey, GeoJsonCollection | GlImage | null>();
const visible = new Map<GlLayerKey, boolean>();
const SOURCES: readonly (GlSource | "satellite")[] = [
"satellite",
"hillshade",
"slope",
"landslide",
"soils",
"zones",
"bands",
"cadastral",
"water",
"building",
"contour",
"road",
"forestRoadFile",
"stream",
"faults",
"lineaments",
"zeroOrder",
"landslideZones",
"place",
];
export function createGlBase(projectId: string, onFail: (why: string) => void): GlBase {
const data = new Map<GlSource, GeoJsonCollection | GlImage | null>();
const visible = new Map<PlanLayerKey, boolean>();
let step = 5;
let theme = "";
let map: MapLibreMap | null = null;
/** 첫 꼴이 섬(`isStyleLoaded` 는 조각 받는 동안 거짓이라 안 씀). */
let ready = false;
const applied = new Map<GlLayerKey, unknown>();
const applied = new Map<string, unknown>();
let container: HTMLDivElement | null = null;
const source = (key: GlLayerKey): SourceSpecification | null => {
const source = (key: GlSource | "satellite"): SourceSpecification | null => {
if (key === "satellite")
return {
type: "raster",
@@ -160,23 +362,25 @@ export function createGlBase(projectId: string): GlBase {
const got = data.get(key);
if (!got) return null;
if ("url" in got) return { type: "image", url: got.url, coordinates: got.corners };
return { type: "geojson", data: got as never, tolerance: 0.375 };
// 묶음 꼴 표시가 없는 자료(여러 겹을 합친 것 등)도 받게 다시 싸 줌
const collection = { type: "FeatureCollection", features: got.features ?? [] };
return { type: "geojson", data: collection as never, tolerance: 0.375 };
};
/** 자료 · 켬끔 · 색을 지도에 맞춤(지도가 다 서면) — 자료는 바뀐 겹만 다시 올림. */
/** 자료 · 켬끔 · 색을 지도에 맞춤(지도가 다 서면) — 자료는 바뀐 소스만 다시 올림. */
function apply(): void {
if (!map || !ready) return;
const now = themeKey();
const specs = layerSpecs(step);
const drop = (key?: GlLayerKey): void => {
const drop = (key?: string): void => {
for (const spec of specs)
if ((!key || spec.key === key) && map!.getLayer(spec.id)) map!.removeLayer(spec.id);
if ((!key || spec.source === key) && map!.getLayer(spec.id)) map!.removeLayer(spec.id);
};
if (now !== theme) {
theme = now;
drop(); // 밝음 · 어두움 = 색 다시
}
for (const key of GL_KEYS) {
for (const key of SOURCES) {
const got = key === "satellite" ? key : (data.get(key) ?? null);
if (applied.get(key) === got) continue;
applied.set(key, got);
@@ -188,7 +392,7 @@ export function createGlBase(projectId: string): GlBase {
if (want) map.addSource(key, want);
}
specs.forEach(({ key, ...spec }, index) => {
if (!map!.getSource(key)) return;
if (!map!.getSource(spec.source)) return;
if (!map!.getLayer(spec.id)) {
// 늦게 온 겹도 제 차례(위 겹 밑)에
const above = specs.slice(index + 1).find((next) => map!.getLayer(next.id));
@@ -198,6 +402,15 @@ export function createGlBase(projectId: string): GlBase {
});
}
function teardown(): void {
map?.remove();
container?.remove();
map = container = null;
ready = false;
theme = "";
applied.clear();
}
return {
setData(key, next) {
if (data.get(key) === next) return;
@@ -215,32 +428,36 @@ export function createGlBase(projectId: string): GlBase {
if (map?.getLayer("contour-minor")) map.setFilter("contour-minor", minorFilter(step));
},
enable(parent) {
if (!parent === !map) return;
if (!parent) {
map?.remove();
container?.remove();
map = container = null;
ready = false;
theme = "";
applied.clear();
return;
teardown();
return false;
}
if (map) return true;
container = document.createElement("div");
container.className = "d03-plan__gl";
parent.prepend(container);
map = new MapLibreMap({
container,
style: { version: 8, sources: {}, layers: [] },
interactive: false,
attributionControl: false,
fadeDuration: 0,
maxZoom: 24,
renderWorldCopies: false,
});
try {
map = new MapLibreMap({
container,
style: { version: 8, sources: {}, layers: [] },
interactive: false,
attributionControl: false,
fadeDuration: 0,
maxZoom: 24,
renderWorldCopies: false,
});
} catch (error) {
teardown();
console.warn("D03 GL 바탕 못 켬", error);
onFail("브라우저가 WebGL 을 열지 못함");
return false;
}
map.on("load", () => {
ready = true;
apply();
});
map.on("webglcontextlost", () => onFail("그래픽 카드 문맥을 잃음"));
return true;
},
map: () => map,
camera(center, zoom, bearingDeg) {
+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));
+3 -1
View File
@@ -92,6 +92,8 @@ def test_screen_def_and_colors() -> None:
assert re.search(rf"^\s+{entry.group(1)}:", icons, re.M)
theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8")
assert all(f"--draw-map-soil-{n}:" in theme for n in range(1, 7))
page = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_Layers.ts").read_text("utf-8")
page = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_LayerPaint.ts").read_text(
"utf-8"
) # 586-2
assert "if (!on.soils || !data.soils) return;" in page
assert page.count("base: true,") == page.count("draw: (context, map) =>") # 겹 모두 바탕(582)
+4 -1
View File
@@ -75,7 +75,10 @@ def test_D03_만_켬():
layers = _read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
assert "indexLayerLod(prepare(collection" in layers
# 581-1 합침 — 겹 모두 바탕 캔버스
assert layers.count("base: true") == layers.count("draw: (context, map)") >= 12 # 겹 모두 바탕(581 · 584 로 늘어남)
paint = _read("D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerPaint.ts") # 586-2 캔버스 그리기 따로
assert (
paint.count("base: true") == paint.count("draw: (context, map)") >= 12
) # 겹 모두 바탕(581 · 584 로 늘어남)
plan_map = _read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts")
assert "requestAnimationFrame(flush)" in plan_map
assert "drawTop();" in plan_map
@@ -230,5 +230,7 @@ def test_screen_defs_icons_colors() -> None:
assert f"--draw-map-{color}:" in theme and f'"--draw-map-{color}"' in defs
for kind in layers.LINE_KINDS:
assert f'kind: "{kind}"' in defs # 서버 열쇠와 같음
page = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_Layers.ts").read_text("utf-8")
page = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_LayerPaint.ts").read_text(
"utf-8"
) # 586-2
assert page.count("base: true,") == page.count("draw: (context, map) =>") # 겹 모두 바탕(582)
@@ -242,7 +242,10 @@ def test_screen_defs() -> None:
defs = (folder / "D03_PlanAnalysis_UI_LayerDefs.ts").read_text("utf-8")
icons = (ROOT / "ui_template" / "ui_template_icons.ts").read_text("utf-8")
theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8")
page = (folder / "D03_PlanAnalysis_UI_Layers.ts").read_text("utf-8")
paint = (folder / "D03_PlanAnalysis_UI_LayerPaint.ts").read_text(
"utf-8"
) # 586-2 캔버스 그리기 따로
page = (folder / "D03_PlanAnalysis_UI_Layers.ts").read_text("utf-8") + paint
legend = (folder / "D03_PlanAnalysis_UI_LayerLegend.ts").read_text("utf-8")
for key, color in (
("forestRoadFile", "forest-road-file"),
@@ -254,8 +257,13 @@ def test_screen_defs() -> None:
assert f"--draw-map-{color}:" in theme and f'"--draw-map-{color}"' in page
assert re.search(r"^\s+upload:", icons, re.M) and "pickFiles(" in page # 부품 상자 고르기 창
assert "<input" not in page and 'createElement("input")' not in page
assert page.count("base: true,") == page.count("draw: (context, map) =>") # 겹 모두 바탕
assert paint.count("base: true,") == paint.count("draw: (context, map) =>") # 겹 모두 바탕
assert "임도 짐작" in legend and "올린 임도망도" in legend # 범례 구분
assert "이 시도 자료 없음" in legend
for name in ("D03_PlanAnalysis_UI_Layers.ts", "D03_PlanAnalysis_UI_LayerLegend.ts"):
for name in (
"D03_PlanAnalysis_UI_Layers.ts",
"D03_PlanAnalysis_UI_LayerLegend.ts",
"D03_PlanAnalysis_UI_LayerPaint.ts",
"D03_PlanAnalysis_UI_MapGl.ts",
):
assert len((folder / name).read_text("utf-8").splitlines()) <= 700
+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