Compare commits
5
Commits
7beca06a06
...
d63aae0e9c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d63aae0e9c | ||
|
|
a9390ca6e0 | ||
|
|
0326c7c33d | ||
|
|
8793b4f6b2 | ||
|
|
ba18b0f96d |
@@ -325,6 +325,8 @@ export interface VWorldMeta {
|
||||
lon_max: number;
|
||||
lat_min: number;
|
||||
lat_max: number;
|
||||
/** 그림 상자 경위도 n×n 고른 마디의 TM 값(`[j * n + i]` · j 남 → 북 · 589-1) — 서버가 응답 때 붙임. */
|
||||
tm_grid?: { n: number; x: number[]; y: number[] };
|
||||
}
|
||||
|
||||
export function getVWorldMapUrl(projectId: string, layerName: string): string {
|
||||
|
||||
@@ -195,3 +195,42 @@ def download_vworld_satellite_map(
|
||||
meta_json_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
|
||||
return map_png_path
|
||||
|
||||
|
||||
# 그림 상자 경위도 격자 한 변 마디 수 — 쌍선형 나머지 용화 0.001 · D 0.006 · 큰 상자 0.014 m(589-1).
|
||||
TM_GRID_NODES = 9
|
||||
|
||||
|
||||
def tm_grid_for_meta(meta: dict, crs_input: str, nodes: int = TM_GRID_NODES) -> dict | None:
|
||||
"""그림 상자 경위도 고른 마디의 TM 값(589-1 · 화면이 경위도 겹을 TM 자리로 놓음).
|
||||
|
||||
마디 `[j * n + i]` = 경도 `lon_min + i/(n-1)·폭` · 위도 `lat_min + j/(n-1)·높이`(j 는 남 → 북).
|
||||
왼위 · 오른아래 마디가 메타 `x_min · y_max` · `x_max · y_min` 과 1 cm 넘게 다르면(메타를 다른
|
||||
좌표계로 만든 것) 격자를 주지 않음 — 화면은 옛 같은 네모 식으로 남음.
|
||||
"""
|
||||
keys = ("lon_min", "lon_max", "lat_min", "lat_max", "x_min", "x_max", "y_min", "y_max")
|
||||
if any(not isinstance(meta.get(key), (int, float)) for key in keys):
|
||||
return None
|
||||
transformer = Transformer.from_crs("EPSG:4326", crs_input, always_xy=True)
|
||||
lon_span = meta["lon_max"] - meta["lon_min"]
|
||||
lat_span = meta["lat_max"] - meta["lat_min"]
|
||||
lons = [meta["lon_min"] + i / (nodes - 1) * lon_span for i in range(nodes)]
|
||||
lats = [meta["lat_min"] + j / (nodes - 1) * lat_span for j in range(nodes)]
|
||||
xs: list[float] = []
|
||||
ys: list[float] = []
|
||||
for lat in lats:
|
||||
x_row, y_row = transformer.transform(lons, [lat] * nodes)
|
||||
xs.extend(float(v) for v in x_row)
|
||||
ys.extend(float(v) for v in y_row)
|
||||
top_left = nodes * (nodes - 1)
|
||||
bottom_right = nodes - 1
|
||||
off = max(
|
||||
abs(xs[top_left] - meta["x_min"]),
|
||||
abs(ys[top_left] - meta["y_max"]),
|
||||
abs(xs[bottom_right] - meta["x_max"]),
|
||||
abs(ys[bottom_right] - meta["y_min"]),
|
||||
)
|
||||
if not math.isfinite(off) or off > 0.01:
|
||||
logger.warning("위성 메타 귀퉁이와 TM 격자가 %.3f m 어긋남 — 격자 안 줌", off)
|
||||
return None
|
||||
return {"n": nodes, "x": [round(v, 4) for v in xs], "y": [round(v, 4) for v in ys]}
|
||||
|
||||
@@ -9,6 +9,7 @@ from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from common_util.common_util_crs import resolve_project_crs
|
||||
from common_util.common_util_http_cache import cached_file_response
|
||||
from common_util.common_util_json import LONLAT_DIGITS, round_floats
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
@@ -25,6 +26,7 @@ from B04_PreProcess.B04_PreProcess_Engine_SheetClip import ( # noqa: E402
|
||||
|
||||
# 수치지형도 도엽 병합 레이어 (SheetParser 산출과 1:1 — 정의는 SHEET_LAYERS가 유일)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import SHEET_LAYERS # noqa: E402
|
||||
from B04_PreProcess.B04_PreProcess_Engine_VWorld import tm_grid_for_meta # noqa: E402
|
||||
|
||||
_SHEET_GEOJSON_FILES = {f"도엽_{k}": f"도엽_{k}.geojson" for k in SHEET_LAYERS}
|
||||
|
||||
@@ -57,7 +59,16 @@ async def get_vworld_meta(
|
||||
"message": f"VWorld {layer_name} 메타데이터를 찾을 수 없습니다.",
|
||||
},
|
||||
)
|
||||
return json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
# 경위도 겹을 TM 자리로 놓을 격자(589-1) — 응답 때 셈 · 파일에 안 씀 · 모르는 화면은 무시.
|
||||
try:
|
||||
grid = tm_grid_for_meta(meta, resolve_project_crs(project_root))
|
||||
except Exception:
|
||||
logger.warning("위성 메타 TM 격자 셈 실패 — 격자 없이 응답", exc_info=True)
|
||||
grid = None
|
||||
if grid is not None:
|
||||
meta["tm_grid"] = grid
|
||||
return meta
|
||||
except Exception as exc:
|
||||
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import type { VWorldMeta } from "./B04_PreProcess_Api_Fetch";
|
||||
import { pickLodList } from "./logic/B04_PreProcess_UI_MapRender_Prepare";
|
||||
import {
|
||||
imageScreenMatrix,
|
||||
normOfLonLat,
|
||||
pickLodList,
|
||||
tmGridNormOf,
|
||||
} from "./logic/B04_PreProcess_UI_MapRender_Prepare";
|
||||
|
||||
// 2D 지도 벡터 레이어 렌더 엔진.
|
||||
// GeoJSON 좌표를 로드 시 1회만 정규화 맵 좌표(0~1)로 사전 투영해 두고,
|
||||
@@ -84,6 +89,8 @@ export type Normalizer = {
|
||||
lonRange: number;
|
||||
latRange: number;
|
||||
aspect: number;
|
||||
/** 경위도 → 정규화 고를 칸(589-1 · `createTmNormalizer`) — 있으면 TM 자리 · 없으면 같은 네모 식. */
|
||||
toNorm?: (lon: number, lat: number) => [number, number];
|
||||
};
|
||||
|
||||
/** 뷰포트 안에서 지도 이미지가 차지하는 사각형(기존 getMapRect와 동일 계산). */
|
||||
@@ -114,6 +121,13 @@ export function createNormalizer(meta: VWorldMeta): Normalizer {
|
||||
};
|
||||
}
|
||||
|
||||
/** 경위도 겹을 TM 겹 자리로 놓는 변환 계수(589-1 · B04 · B05) — 메타에 `tm_grid` 가 없으면 `createNormalizer` 그대로. */
|
||||
export function createTmNormalizer(meta: VWorldMeta): Normalizer {
|
||||
const base = createNormalizer(meta);
|
||||
const toNorm = tmGridNormOf(meta);
|
||||
return toNorm ? { ...base, toNorm } : base;
|
||||
}
|
||||
|
||||
export function computeMapRect(meta: VWorldMeta | null, width: number, height: number): MapRect {
|
||||
if (!meta) return { x: 0, y: 0, width, height };
|
||||
const mapRatio = meta.width_meters / Math.max(meta.height_meters, 1);
|
||||
@@ -338,11 +352,35 @@ export function lonLatToScreen(
|
||||
lon: number,
|
||||
lat: number,
|
||||
): [x: number, y: number] {
|
||||
return normalizedToScreen(
|
||||
view,
|
||||
(lon - normalizer.lonMin) / normalizer.lonRange,
|
||||
1 - (lat - normalizer.latMin) / normalizer.latRange,
|
||||
const [nx, ny] = normOfLonLat(normalizer, lon, lat);
|
||||
return normalizedToScreen(view, nx, ny);
|
||||
}
|
||||
|
||||
/**
|
||||
* 위성 그림(`<img>` 제 크기 · 원점 왼위)을 화면에 놓음(589-1) — 그림 px → 경위도 → `normalizer` → 화면을
|
||||
* 화면 가운데에서 1차로 맞춘 행렬. 그림 · 메타가 아직 없으면 그대로 둠.
|
||||
*/
|
||||
export function placeMapImage(
|
||||
image: HTMLImageElement,
|
||||
meta: VWorldMeta | null,
|
||||
normalizer: Normalizer | null,
|
||||
view: ViewState,
|
||||
): void {
|
||||
if (!meta || !normalizer || !image.naturalWidth || !image.naturalHeight) return;
|
||||
const affine = affineOf(view);
|
||||
const matrix = imageScreenMatrix(
|
||||
meta,
|
||||
normalizer,
|
||||
image.naturalWidth,
|
||||
image.naturalHeight,
|
||||
(nx, ny) => [nx * affine.ax + affine.bx, ny * affine.ay + affine.by],
|
||||
view.width / 2,
|
||||
view.height / 2,
|
||||
);
|
||||
image.style.transform = `matrix(${matrix.join(", ")})`;
|
||||
// 크게 당기면 배경 그림이 뭉개진다 — 흐림 보간을 끄고 픽셀을 그대로 보인다
|
||||
// (2026-09-04 사용자 지시). 실제 크기는 축척 막대로 읽는다.
|
||||
image.style.imageRendering = view.scale > 4 ? "pixelated" : "auto";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,9 +34,10 @@ import {
|
||||
computeRouteView,
|
||||
createMetricProjector,
|
||||
MAP_STATION_INTERVAL_M,
|
||||
createNormalizer,
|
||||
createTmNormalizer,
|
||||
drawPreparedLabels,
|
||||
drawPreparedLayer,
|
||||
placeMapImage,
|
||||
routeLineColor,
|
||||
ROUTE_LINE_WIDTH,
|
||||
type MapRect,
|
||||
@@ -108,6 +109,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
image.className = "b04-map__image";
|
||||
image.alt = L("B04_Surface_Map_ImageAlt");
|
||||
image.draggable = false;
|
||||
image.addEventListener("load", () => scheduleDraw());
|
||||
backgroundImages.set(layer, image);
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
@@ -308,13 +310,9 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
// 강도 색띠 범례도 같은 자리에 얹는다(CSS가 우측 세로로 세운다).
|
||||
viewport.append(detailBasins.menuElement, flowStrength.legendElement);
|
||||
|
||||
function updateImageTransform(): void {
|
||||
backgroundImages.forEach((image) => {
|
||||
image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
|
||||
// 크게 당기면 배경 그림이 뭉개진다 — 흐림 보간을 끄고 픽셀을 그대로 보인다
|
||||
// (2026-09-04 사용자 지시). 실제 크기는 축척 막대로 읽는다.
|
||||
image.style.imageRendering = scale > 4 ? "pixelated" : "auto";
|
||||
});
|
||||
/** 배경 그림을 TM 겹 자리로 놓음(589-1 · 공용 `placeMapImage`). */
|
||||
function updateImageTransform(view: ViewState): void {
|
||||
backgroundImages.forEach((image) => placeMapImage(image, meta, normalizer, view));
|
||||
}
|
||||
|
||||
function syncLayerVisibility(): void {
|
||||
@@ -346,7 +344,6 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
|
||||
function resetView(): void {
|
||||
fitRouteView();
|
||||
updateImageTransform();
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
@@ -432,7 +429,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
detailBasins.draw(context, normalizer, view);
|
||||
// 유입 집중점(집중유역)은 맨 마지막 — 켰을 때 무엇에도 가리지 않아야 고를 수 있다.
|
||||
flowStrength.drawTop(context, normalizer, view);
|
||||
updateImageTransform();
|
||||
updateImageTransform(view);
|
||||
drawScaleBar(mapRect);
|
||||
}
|
||||
|
||||
@@ -489,7 +486,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
if (sequence !== loadSequence) return;
|
||||
meta = nextMeta;
|
||||
// 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다.
|
||||
normalizer = createNormalizer(nextMeta);
|
||||
normalizer = createTmNormalizer(nextMeta);
|
||||
routeLayer =
|
||||
planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null;
|
||||
routePoints = planned.points;
|
||||
|
||||
@@ -77,9 +77,14 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 그림은 제 크기 · 원점 왼위 — 자리는 TM 겹에 맞춘 행렬(`placeMapImage` · 589-1). */
|
||||
.b04-map__image {
|
||||
object-fit: contain;
|
||||
transform-origin: center;
|
||||
inset: 0 auto auto 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
transform-origin: 0 0;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { themeColor } from "@ui/ui_template_palette";
|
||||
import { createLayerToggle, setLayerLabel } from "./B04_PreProcess_UI_LayerToggle";
|
||||
import { fetchWatershedAnalysis, type WatershedAnalysis } from "./B04_PreProcess_Api_Fetch";
|
||||
import { drawFlowArrows } from "./B04_PreProcess_UI_FlowArrows";
|
||||
import type { Normalizer, ViewState } from "./B04_PreProcess_UI_MapRender";
|
||||
import { lonLatToScreen, type Normalizer, type ViewState } from "./B04_PreProcess_UI_MapRender";
|
||||
import { drawUpstreamLines } from "./B04_PreProcess_UI_MapOverlays";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
@@ -167,14 +167,9 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
|
||||
view: ViewState,
|
||||
): void {
|
||||
if (line.length < 2) return;
|
||||
const ax = view.mapRect.width * view.scale;
|
||||
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
|
||||
const ay = view.mapRect.height * view.scale;
|
||||
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
|
||||
context.beginPath();
|
||||
line.forEach(([lon, lat], index) => {
|
||||
const x = ((lon - map.lonMin) / map.lonRange) * ax + bx;
|
||||
const y = (1 - (lat - map.latMin) / map.latRange) * ay + by;
|
||||
const [x, y] = lonLatToScreen(map, view, lon, lat);
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
@@ -205,16 +200,12 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
|
||||
): void {
|
||||
const ring = region.grid.bbox_lonlat;
|
||||
if (ring.length < 4) return;
|
||||
const lons = ring.map(([lon]) => lon);
|
||||
const lats = ring.map(([, lat]) => lat);
|
||||
const ax = view.mapRect.width * view.scale;
|
||||
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
|
||||
const ay = view.mapRect.height * view.scale;
|
||||
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
|
||||
const left = ((Math.min(...lons) - map.lonMin) / map.lonRange) * ax + bx;
|
||||
const right = ((Math.max(...lons) - map.lonMin) / map.lonRange) * ax + bx;
|
||||
const top = (1 - (Math.max(...lats) - map.latMin) / map.latRange) * ay + by;
|
||||
const bottom = (1 - (Math.min(...lats) - map.latMin) / map.latRange) * ay + by;
|
||||
// 격자 귀퉁이를 화면으로 옮긴 뒤 감싸는 네모 — TM 1 m 격자는 TM 자리(589-1)에서 곧은 네모.
|
||||
const corners = ring.map(([lon, lat]) => lonLatToScreen(map, view, lon, lat));
|
||||
const left = Math.min(...corners.map(([x]) => x));
|
||||
const right = Math.max(...corners.map(([x]) => x));
|
||||
const top = Math.min(...corners.map(([, y]) => y));
|
||||
const bottom = Math.max(...corners.map(([, y]) => y));
|
||||
|
||||
const { rows, cols, row_spans: spans } = region.grid;
|
||||
const cellW = (right - left) / Math.max(cols, 1);
|
||||
@@ -386,24 +377,16 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
|
||||
view: ViewState,
|
||||
region: WatershedAnalysis,
|
||||
): void {
|
||||
// 격자 bbox의 경도 폭과 실폭(m)으로 1m당 픽셀을 환산한다.
|
||||
const lons = region.grid.bbox_lonlat.map(([lon]) => lon);
|
||||
const spanLon = Math.max(...lons) - Math.min(...lons);
|
||||
if (!(spanLon > 0) || !(region.grid.width_m > 0)) return;
|
||||
const ax = view.mapRect.width * view.scale;
|
||||
const pxPerMeter = ((spanLon / map.lonRange) * ax) / region.grid.width_m;
|
||||
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
|
||||
const ay = view.mapRect.height * view.scale;
|
||||
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
|
||||
// 격자 bbox의 화면 폭과 실폭(m)으로 1m당 픽셀을 환산한다.
|
||||
const xs = region.grid.bbox_lonlat.map(([lon, lat]) => lonLatToScreen(map, view, lon, lat)[0]);
|
||||
const spanPx = Math.max(...xs) - Math.min(...xs);
|
||||
if (!(spanPx > 0) || !(region.grid.width_m > 0)) return;
|
||||
drawFlowArrows(
|
||||
context,
|
||||
region.flow_arrows ?? [],
|
||||
region.arrow_spacing_m ?? 0,
|
||||
pxPerMeter,
|
||||
(lon, lat) => [
|
||||
((lon - map.lonMin) / map.lonRange) * ax + bx,
|
||||
(1 - (lat - map.latMin) / map.latRange) * ay + by,
|
||||
],
|
||||
spanPx / region.grid.width_m,
|
||||
(lon, lat) => lonLatToScreen(map, view, lon, lat),
|
||||
view,
|
||||
);
|
||||
}
|
||||
@@ -423,13 +406,8 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
|
||||
strokeLonLat(context, region.basin_polygon_lonlat, map, view);
|
||||
}
|
||||
context.setLineDash([]);
|
||||
const ax = view.mapRect.width * view.scale;
|
||||
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
|
||||
const ay = view.mapRect.height * view.scale;
|
||||
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
|
||||
region.pipes.forEach((pipe, index) => {
|
||||
const x = ((pipe.lon - map.lonMin) / map.lonRange) * ax + bx;
|
||||
const y = (1 - (pipe.lat - map.latMin) / map.latRange) * ay + by;
|
||||
const [x, y] = lonLatToScreen(map, view, pipe.lon, pipe.lat);
|
||||
context.beginPath();
|
||||
context.arc(x, y, 7, 0, Math.PI * 2);
|
||||
context.fillStyle = pipeMarkerColor();
|
||||
|
||||
@@ -51,6 +51,105 @@ export function indexLayerLod(layer: PreparedLayer): PreparedLayer {
|
||||
return layer;
|
||||
}
|
||||
|
||||
/** 경위도 → 정규화(0~1) — 고를 칸 `toNorm` 이 있으면 그것(TM 자리) · 없으면 같은 네모 식. */
|
||||
export function normOfLonLat(normalizer: Normalizer, lon: number, lat: number): [number, number] {
|
||||
if (normalizer.toNorm) return normalizer.toNorm(lon, lat);
|
||||
return [
|
||||
(lon - normalizer.lonMin) / normalizer.lonRange,
|
||||
1 - (lat - normalizer.latMin) / normalizer.latRange,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 경위도 → TM 정규화(589-1) — 서버 `tm_grid`(그림 상자 경위도 고른 마디의 TM 값) 쌍선형 →
|
||||
* `((x − x_min)/W, 1 − (y − y_min)/H)` = `metricToScreen` 과 같은 자. 상자 밖은 가장자리 칸을 늘려 씀.
|
||||
* 격자가 없거나 모양이 틀리면 null.
|
||||
*/
|
||||
export function tmGridNormOf(
|
||||
meta: VWorldMeta,
|
||||
): ((lon: number, lat: number) => [number, number]) | null {
|
||||
const grid = meta.tm_grid;
|
||||
const n = grid?.n ?? 0;
|
||||
if (!grid || n < 2 || grid.x.length !== n * n || grid.y.length !== n * n) return null;
|
||||
const { x: gx, y: gy } = grid;
|
||||
const cells = n - 1;
|
||||
const lonRange = meta.lon_max - meta.lon_min || 1;
|
||||
const latRange = meta.lat_max - meta.lat_min || 1;
|
||||
const spanX = meta.width_meters || 1;
|
||||
const spanY = meta.height_meters || 1;
|
||||
return (lon, lat) => {
|
||||
const u = ((lon - meta.lon_min) / lonRange) * cells;
|
||||
const v = ((lat - meta.lat_min) / latRange) * cells;
|
||||
const i = Math.min(Math.max(Math.floor(u), 0), cells - 1);
|
||||
const j = Math.min(Math.max(Math.floor(v), 0), cells - 1);
|
||||
const fu = u - i;
|
||||
const fv = v - j;
|
||||
const k = j * n + i;
|
||||
const w00 = (1 - fu) * (1 - fv);
|
||||
const w10 = fu * (1 - fv);
|
||||
const w01 = (1 - fu) * fv;
|
||||
const w11 = fu * fv;
|
||||
const x = w00 * gx[k] + w10 * gx[k + 1] + w01 * gx[k + n] + w11 * gx[k + n + 1];
|
||||
const y = w00 * gy[k] + w10 * gy[k + 1] + w01 * gy[k + n] + w11 * gy[k + n + 1];
|
||||
return [(x - meta.x_min) / spanX, 1 - (y - meta.y_min) / spanY];
|
||||
};
|
||||
}
|
||||
|
||||
const mercatorOf = (lat: number): number => Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360));
|
||||
const latOfMercator = (m: number): number => (Math.atan(Math.exp(m)) * 360) / Math.PI - 90;
|
||||
|
||||
/**
|
||||
* 위성 그림 px → 화면 CSS 행렬 `[a, b, c, d, e, f]`(원점 그림 왼위 · 589-1).
|
||||
* 그림 = 웹 메르카토르 타일 이음(가로 경도 선형 · 세로 메르카토르 y 선형) → 경위도 → `normalizer` →
|
||||
* `toScreen`. 이 길을 화면 (`centerX`, `centerY`) 에 닿는 그림 px 에서 1차로 맞춤 — 옮김 · 확대마다 다시 셈.
|
||||
*/
|
||||
export function imageScreenMatrix(
|
||||
meta: VWorldMeta,
|
||||
normalizer: Normalizer,
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
toScreen: (nx: number, ny: number) => [number, number],
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
): [number, number, number, number, number, number] {
|
||||
const lonRange = meta.lon_max - meta.lon_min;
|
||||
const mTop = mercatorOf(meta.lat_max);
|
||||
const mSpan = mTop - mercatorOf(meta.lat_min);
|
||||
const screenOf = (u: number, v: number): [number, number] => {
|
||||
const lon = meta.lon_min + (u / imageWidth) * lonRange;
|
||||
const lat = latOfMercator(mTop - (v / imageHeight) * mSpan);
|
||||
const [nx, ny] = normOfLonLat(normalizer, lon, lat);
|
||||
return toScreen(nx, ny);
|
||||
};
|
||||
let u = imageWidth / 2;
|
||||
let v = imageHeight / 2;
|
||||
let a = 1;
|
||||
let b = 0;
|
||||
let c = 0;
|
||||
let d = 1;
|
||||
let at = screenOf(u, v);
|
||||
// 뉴턴 셋 — 길이 거의 1차라 한두 번에 화면 가운데 그림 px 가 잡힘.
|
||||
for (let step = 0; step < 3; step += 1) {
|
||||
const h = 0.5;
|
||||
const du0 = screenOf(u - h, v);
|
||||
const du1 = screenOf(u + h, v);
|
||||
const dv0 = screenOf(u, v - h);
|
||||
const dv1 = screenOf(u, v + h);
|
||||
a = (du1[0] - du0[0]) / (2 * h);
|
||||
b = (du1[1] - du0[1]) / (2 * h);
|
||||
c = (dv1[0] - dv0[0]) / (2 * h);
|
||||
d = (dv1[1] - dv0[1]) / (2 * h);
|
||||
const det = a * d - b * c;
|
||||
if (!Number.isFinite(det) || Math.abs(det) < 1e-12) break;
|
||||
const ex = centerX - at[0];
|
||||
const ey = centerY - at[1];
|
||||
u += (d * ex - c * ey) / det;
|
||||
v += (a * ey - b * ex) / det;
|
||||
at = screenOf(u, v);
|
||||
}
|
||||
return [a, b, c, d, at[0] - a * u - c * v, at[1] - b * u - d * v];
|
||||
}
|
||||
|
||||
function isPoint(value: unknown): value is [number, number] {
|
||||
return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number";
|
||||
}
|
||||
@@ -62,8 +161,9 @@ function projectRing(ring: unknown, normalizer: Normalizer): Float64Array | null
|
||||
let count = 0;
|
||||
for (const point of ring) {
|
||||
if (!isPoint(point)) continue;
|
||||
coords[count * 2] = (point[0] - normalizer.lonMin) / normalizer.lonRange;
|
||||
coords[count * 2 + 1] = 1 - (point[1] - normalizer.latMin) / normalizer.latRange;
|
||||
const [nx, ny] = normOfLonLat(normalizer, point[0], point[1]);
|
||||
coords[count * 2] = nx;
|
||||
coords[count * 2 + 1] = ny;
|
||||
count += 1;
|
||||
}
|
||||
if (count === 0) return null;
|
||||
@@ -166,10 +266,7 @@ function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [numb
|
||||
if (!Array.isArray(line) || line.length === 0) return null;
|
||||
const mid = line[Math.floor(line.length / 2)];
|
||||
if (!isPoint(mid)) return null;
|
||||
return [
|
||||
(mid[0] - normalizer.lonMin) / normalizer.lonRange,
|
||||
1 - (mid[1] - normalizer.latMin) / normalizer.latRange,
|
||||
];
|
||||
return normOfLonLat(normalizer, mid[0], mid[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
import {
|
||||
computeMapRect,
|
||||
MAP_STATION_INTERVAL_M,
|
||||
createNormalizer,
|
||||
createTmNormalizer,
|
||||
placeMapImage,
|
||||
type MapRect,
|
||||
type Normalizer,
|
||||
type PreparedLayer,
|
||||
@@ -186,12 +187,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
redraw: () => scheduleDraw(),
|
||||
});
|
||||
|
||||
function updateImageTransform(): void {
|
||||
backgroundImage.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
|
||||
// 크게 당기면 도엽 그림이 뭉개진다 — 흐림 보간을 끄고 픽셀을 그대로 보인다
|
||||
// (2026-09-04 사용자 지시). 축척 막대가 실제 크기를 알려 준다.
|
||||
backgroundImage.style.imageRendering = scale > 4 ? "pixelated" : "auto";
|
||||
}
|
||||
backgroundImage.addEventListener("load", () => scheduleDraw());
|
||||
|
||||
function draw(): void {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
@@ -238,7 +234,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
stationIntervalM: MAP_STATION_INTERVAL_M,
|
||||
intervalSpans,
|
||||
});
|
||||
updateImageTransform();
|
||||
placeMapImage(backgroundImage, meta, normalizer, view);
|
||||
}
|
||||
|
||||
/** 현재 프레임 뷰 상태 (draw()와 동일 계산 — 배관 마커 포인터 히트 판정용). */
|
||||
@@ -474,7 +470,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
);
|
||||
if (sequence !== loadSequence) return;
|
||||
meta = nextMeta;
|
||||
normalizer = createNormalizer(nextMeta);
|
||||
normalizer = createTmNormalizer(nextMeta);
|
||||
let featureCount = 0;
|
||||
loaded.forEach(([layer, data]) => {
|
||||
if (!data) return;
|
||||
|
||||
@@ -12,8 +12,9 @@ import type { DetailBasin, VWorldMeta } from "../B04_PreProcess/B04_PreProcess_A
|
||||
import {
|
||||
computeMapRect,
|
||||
computeRouteView,
|
||||
createNormalizer,
|
||||
createTmNormalizer,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import { normOfLonLat } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
|
||||
|
||||
/** 노선 범위를 몇 배까지 멀리 볼지 — 키우면 더 멀리 빠진다(사용자가 바꿀 수 있는 값). */
|
||||
export const ZOOM_OUT_ROUTE_FACTOR = 2;
|
||||
@@ -50,11 +51,10 @@ export function computeZoomOutLimit(
|
||||
let x1 = centerX + halfW;
|
||||
let y0 = centerY - halfH;
|
||||
let y1 = centerY + halfH;
|
||||
const normalizer = createNormalizer(meta);
|
||||
const normalizer = createTmNormalizer(meta);
|
||||
basins.forEach((basin) =>
|
||||
basin.polygon_lonlat.forEach(([lon, lat]) => {
|
||||
const nx = (lon - normalizer.lonMin) / normalizer.lonRange;
|
||||
const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange;
|
||||
const [nx, ny] = normOfLonLat(normalizer, lon, lat);
|
||||
x0 = Math.min(x0, nx);
|
||||
x1 = Math.max(x1, nx);
|
||||
y0 = Math.min(y0, ny);
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import { contourAssetUrl, fetchCachedJson } from "../A00_Common/b_asset_cache";
|
||||
import {
|
||||
createNormalizer,
|
||||
createTmNormalizer,
|
||||
type GeoJsonCollection,
|
||||
type Normalizer,
|
||||
type PreparedLayer,
|
||||
@@ -192,7 +192,7 @@ export async function loadRouteEditBackground(
|
||||
reuse: RouteEditContours | null = null,
|
||||
): Promise<{ otherSheets: PreparedLayer[]; contours: RouteEditContours; sheets: SheetLayers }> {
|
||||
const drainage = await coveringSheets(projectId, clipped);
|
||||
const normalizer = createNormalizer(drainage.meta);
|
||||
const normalizer = createTmNormalizer(drainage.meta);
|
||||
const otherSheets = drainage.layers
|
||||
.filter(([layer]) => layer !== "도엽_등고선")
|
||||
.map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null))
|
||||
|
||||
@@ -119,9 +119,14 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 그림은 제 크기 · 원점 왼위 — 자리는 TM 겹에 맞춘 행렬(`placeMapImage` · 589-1). */
|
||||
.b05-drainage__image {
|
||||
object-fit: contain;
|
||||
transform-origin: center;
|
||||
inset: 0 auto auto 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
transform-origin: 0 0;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -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 그대로
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
]),
|
||||
),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""589-1 B04 · B05 지도 — 경위도 겹(도엽 · 지적 · 유역 · 위성 그림)을 TM 겹(노선 · 관) 자리에.
|
||||
|
||||
- 서버 `tm_grid_for_meta` 마디 = pyproj · 왼위 · 오른아래 마디 = 메타 귀퉁이 1 mm 안(Bessel · GRS80)
|
||||
- 메타 귀퉁이와 다른 좌표계면 격자 안 줌
|
||||
- TS `normOfLonLat`(격자 쌍선형) = pyproj TM 자리 0.02 m 안 · 격자 없으면 옛 같은 네모 식 그대로
|
||||
- 위성 그림 행렬(화면 가운데 1차) = 그림 px → 경위도 → pyproj TM 자리와 화면 안 0.5 px 안
|
||||
- 옛 같은 네모 꼴은 귀퉁이에서 용화 ≥ 3 m · D ≥ 60 m 어긋남(고친 까닭)
|
||||
|
||||
Node 헬퍼(`helper_589_tm_grid.cjs`)가 진짜 TS 를 돌린다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
from pyproj import Transformer
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_VWorld import tm_grid_for_meta
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
BESSEL = (
|
||||
'PROJCS["Korean 1985 / Modified East Belt",GEOGCS["Korean 1985",DATUM["Korean Datum 1985",'
|
||||
'SPHEROID["Bessel 1841",6377397.155,299.1528128],TOWGS84[-114.61998513841,475.96296986986,'
|
||||
'675.01832868573,1.162,-2.347,-1.592,6.342]],PRIMEM["Greenwich",0],UNIT["degree",'
|
||||
'0.01745329251994328]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",38],'
|
||||
'PARAMETER["central_meridian",129.002890277778],PARAMETER["scale_factor",1],'
|
||||
'PARAMETER["false_easting",200000],PARAMETER["false_northing",500000],UNIT["metre",1]]'
|
||||
)
|
||||
# 검증 B(용화 · Bessel) · D(5186) 위성 상자(lon_min, lon_max, lat_min, lat_max) · 그림 px
|
||||
SITES = {
|
||||
"B": (
|
||||
BESSEL,
|
||||
(129.0728759765625, 129.100341796875, 36.798288873837045, 36.82687474287728),
|
||||
(2560, 3328),
|
||||
),
|
||||
"D": (
|
||||
"EPSG:5186",
|
||||
(127.9193115234375, 127.979736328125, 36.270850207239036, 36.30184530368433),
|
||||
(2816, 1792),
|
||||
),
|
||||
}
|
||||
VIEWS = [(1400, 560, 1, 0, 0), (1400, 560, 2.33, -120, 80), (1400, 560, 300, -60000, 40000)]
|
||||
|
||||
|
||||
def _meta(crs, box):
|
||||
"""서버 식 그대로 — 왼위 · 오른아래 두 귀퉁이만 TM 으로."""
|
||||
lon_min, lon_max, lat_min, lat_max = box
|
||||
fwd = Transformer.from_crs("EPSG:4326", crs, always_xy=True)
|
||||
x_min, y_max = fwd.transform(lon_min, lat_max)
|
||||
x_max, y_min = fwd.transform(lon_max, lat_min)
|
||||
return {
|
||||
"x_min": x_min,
|
||||
"x_max": x_max,
|
||||
"y_min": y_min,
|
||||
"y_max": y_max,
|
||||
"width_meters": x_max - x_min,
|
||||
"height_meters": y_max - y_min,
|
||||
"center_x": (x_min + x_max) / 2,
|
||||
"center_y": (y_min + y_max) / 2,
|
||||
"lon_min": lon_min,
|
||||
"lon_max": lon_max,
|
||||
"lat_min": lat_min,
|
||||
"lat_max": lat_max,
|
||||
}
|
||||
|
||||
|
||||
def _merc(lat):
|
||||
return math.log(math.tan(math.pi / 4 + math.radians(lat) / 2))
|
||||
|
||||
|
||||
def _lonlat_of_px(meta, size, u, v):
|
||||
"""그림 px → 경위도(가로 경도 선형 · 세로 메르카토르 y 선형)."""
|
||||
top, bottom = _merc(meta["lat_max"]), _merc(meta["lat_min"])
|
||||
lon = meta["lon_min"] + u / size[0] * (meta["lon_max"] - meta["lon_min"])
|
||||
m = top - v / size[1] * (top - bottom)
|
||||
return lon, math.degrees(2 * math.atan(math.exp(m)) - math.pi / 2)
|
||||
|
||||
|
||||
def _run(site, with_grid=True):
|
||||
crs, box, size = SITES[site]
|
||||
meta = _meta(crs, box)
|
||||
if with_grid:
|
||||
meta["tm_grid"] = tm_grid_for_meta(meta, crs)
|
||||
lon_min, lon_max, lat_min, lat_max = box
|
||||
lonlat = [
|
||||
(lon_min + a / 10 * (lon_max - lon_min), lat_min + b / 10 * (lat_max - lat_min))
|
||||
for a in range(11)
|
||||
for b in range(11)
|
||||
]
|
||||
payload = {"meta": meta, "lonlat": lonlat, "image": size, "views": VIEWS}
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_589_tm_grid.cjs")],
|
||||
input=json.dumps(payload),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return meta, lonlat, json.loads(proc.stdout)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", params=["B", "D"])
|
||||
def site(request):
|
||||
return request.param, *_run(request.param)
|
||||
|
||||
|
||||
def test_서버_격자는_pyproj_마디(site):
|
||||
name, meta, _, _ = site
|
||||
grid = meta["tm_grid"]
|
||||
n = grid["n"]
|
||||
assert n == 9 and len(grid["x"]) == n * n
|
||||
assert abs(grid["x"][n * (n - 1)] - meta["x_min"]) < 1e-3
|
||||
assert abs(grid["y"][n * (n - 1)] - meta["y_max"]) < 1e-3
|
||||
assert abs(grid["x"][n - 1] - meta["x_max"]) < 1e-3
|
||||
assert abs(grid["y"][n - 1] - meta["y_min"]) < 1e-3
|
||||
fwd = Transformer.from_crs("EPSG:4326", SITES[name][0], always_xy=True)
|
||||
i, j = 3, 5
|
||||
lon = meta["lon_min"] + i / (n - 1) * (meta["lon_max"] - meta["lon_min"])
|
||||
lat = meta["lat_min"] + j / (n - 1) * (meta["lat_max"] - meta["lat_min"])
|
||||
x, y = fwd.transform(lon, lat)
|
||||
assert math.hypot(grid["x"][j * n + i] - x, grid["y"][j * n + i] - y) < 1e-3
|
||||
|
||||
|
||||
def test_다른_좌표계_메타면_격자_안_줌():
|
||||
meta = _meta("EPSG:5186", SITES["D"][1])
|
||||
assert tm_grid_for_meta(meta, "EPSG:5187") is None
|
||||
|
||||
|
||||
def test_TS_격자_자리는_pyproj_TM(site):
|
||||
name, meta, lonlat, out = site
|
||||
assert out["hasToNorm"]
|
||||
fwd = Transformer.from_crs("EPSG:4326", SITES[name][0], always_xy=True)
|
||||
worst = 0.0
|
||||
for (lon, lat), (nx, ny) in zip(lonlat, out["tm"]):
|
||||
x, y = fwd.transform(lon, lat)
|
||||
gx = meta["x_min"] + nx * meta["width_meters"]
|
||||
gy = meta["y_min"] + (1 - ny) * meta["height_meters"]
|
||||
worst = max(worst, math.hypot(gx - x, gy - y))
|
||||
assert worst < 0.02, worst
|
||||
|
||||
|
||||
def test_격자_없으면_옛_식_그대로():
|
||||
meta, lonlat, out = _run("B", with_grid=False)
|
||||
assert not out["hasToNorm"]
|
||||
for (lon, lat), tm, old, screen in zip(lonlat, out["tm"], out["old"], out["oldScreen"]):
|
||||
want = [
|
||||
(lon - meta["lon_min"]) / (meta["lon_max"] - meta["lon_min"]),
|
||||
1 - (lat - meta["lat_min"]) / (meta["lat_max"] - meta["lat_min"]),
|
||||
]
|
||||
assert tm == want and old == want and screen == want
|
||||
|
||||
|
||||
def test_위성_그림_행렬은_TM_자리(site):
|
||||
name, meta, _, out = site
|
||||
crs, _, size = SITES[name]
|
||||
fwd = Transformer.from_crs("EPSG:4326", crs, always_xy=True)
|
||||
for (w, h, scale, ox, oy), got in zip(VIEWS, out["views"]):
|
||||
rect = got["mapRect"]
|
||||
a, b, c, d, e, f = got["matrix"]
|
||||
ax, ay = rect["width"] * scale, rect["height"] * scale
|
||||
bx = rect["x"] * scale + w / 2 * (1 - scale) + ox
|
||||
by = rect["y"] * scale + h / 2 * (1 - scale) + oy
|
||||
if scale < 10:
|
||||
# 그림 귀퉁이 · 가장자리 가운데 · 가운데
|
||||
probes = [(u, v) for u in (0, size[0] / 2, size[0]) for v in (0, size[1] / 2, size[1])]
|
||||
else:
|
||||
# 크게 당김 — 화면 귀퉁이 · 가운데에 놓인 그림 px
|
||||
det = a * d - b * c
|
||||
probes = []
|
||||
for sx, sy in ((0, 0), (w, 0), (0, h), (w, h), (w / 2, h / 2)):
|
||||
du, dv = sx - e, sy - f
|
||||
probes.append(((d * du - c * dv) / det, (a * dv - b * du) / det))
|
||||
for u, v in probes:
|
||||
x, y = fwd.transform(*_lonlat_of_px(meta, size, u, v))
|
||||
want = (
|
||||
(x - meta["x_min"]) / meta["width_meters"] * ax + bx,
|
||||
(1 - (y - meta["y_min"]) / meta["height_meters"]) * ay + by,
|
||||
)
|
||||
got_px = (a * u + c * v + e, b * u + d * v + f)
|
||||
gap = math.hypot(got_px[0] - want[0], got_px[1] - want[1])
|
||||
assert gap < 0.5, (name, scale, u, v, gap)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,least", [("B", 3.0), ("D", 60.0)])
|
||||
def test_옛_같은_네모_꼴은_귀퉁이에서_어긋남(name, least):
|
||||
crs, box, _ = SITES[name]
|
||||
meta = _meta(crs, box)
|
||||
x, y = Transformer.from_crs("EPSG:4326", crs, always_xy=True).transform(box[1], box[3])
|
||||
# 옛 식 = 오른위 귀퉁이 경위도를 상자 오른위(x_max, y_max) 에 둠
|
||||
assert math.hypot(meta["x_max"] - x, meta["y_max"] - y) >= least
|
||||
Reference in New Issue
Block a user