feat(B04·B05): 하단 지도 · 평면 노선 편집 · 배수유역도 바탕 MapLibre 적용 — 공용 GL 부품 · TM 주인 카메라 · 처음 = 켬 · WebGL 못 쓰면 옛 캔버스 (PLAN 589-2 · 요구 448)
- 공용 부품 ui_template_gl_map(지도 세우기 · 자료 · 켬끔 · 색 다시 · 카메라) — D03 GL 껍데기를 올림 · D03 은 겹 꼴만(바뀜 없음) - TM 주인 따라가기(B04_PreProcess_UI_MapGl · logic/_MapGl_Frame) — 화면 가운데 1차 맞춤 닮음꼴 카메라 + 남는 늘림(웹 메르카토르 가로세로 0.43 %)은 지도 칸 CSS 행렬 · 칸 둘레 여유 8 px - B04 하단 지도 — 위성 조각(줌인 선명) · 백지도 · 혼합 · 도엽 · 지적 · 행정 · 등고선 겹 GL · 등고 글 · 배수유역 · 계획선 · 관 = 위 캔버스 - B05 배수유역도 — 위성 · 세부유역 채움(짝홀 다각형) · 분수령 · 도엽 GL · 번호 · 상류 세류 · 관 · 강도 = 위 캔버스 - B05 평면 노선 편집 — 바탕칠 · LAS · 도엽 등고선(간격 거름 · 1 m 선) · 하천(LAS 상자 자름) GL · 노선 둘레 띠 = 지도 칸 clip-path · 고른 등고선 · 글 · 노선 · 꼭짓점 = 위 캔버스 - 켬끔 칸(GPU · 세 화면 한 취향 aislo:pref:map.canvas) · WebGL 못 씀 · 잃음 = 옛 캔버스 + 알림 · 누름 · 끌기 판정 손 안 댐 - B04 층 단추 글 = MapLayers gisLayerLabel(지도 본체 700 줄) - 지킴 시험 test_589_2_gl_frame(카메라 대 캔버스 0.5 px · 칸 행렬 늘이기만 · 되돌림 1 mm · 상자 자르기 · 짝홀 다각형) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
/* =============================================================================
|
||||
* B04_PreProcess_UI_MapGl.ts
|
||||
* TM 주인 화면의 GL 바탕(PLAN 589-2 · 요구 448) — B04 하단 지도 · B05 평면 노선 편집 · 배수유역도.
|
||||
* 공용 부품 `ui_template_gl_map`(MapLibre) 을 지도 칸 맨 밑에 깔고, 화면(`ViewState` · 돌림)을
|
||||
* 따라감 — 카메라 = 화면 가운데 1차 맞춤(`logic/…_MapGl_Frame` · 닮음꼴) + 남는 늘림 = 지도 칸 CSS 행렬 · 위 그림(노선 · 관 · 측점 · 고르기)은
|
||||
* 위 캔버스 그대로 · 누름 · 끌기 판정 그대로.
|
||||
* 처음 = 켬 · 끔 = 옛 캔버스(취향 `aislo:pref:map.canvas` = "1" · 세 화면 하나) · WebGL 못 씀 · 잃음 =
|
||||
* 저절로 옛 캔버스 + 알림(취향은 안 바꿈).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { ExpressionSpecification, FilterSpecification } from "maplibre-gl";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
createGlMap,
|
||||
GL_FONT,
|
||||
satelliteTileSource,
|
||||
type GlMap,
|
||||
type GlRaster,
|
||||
type GlSpec,
|
||||
} from "@ui/ui_template_gl_map";
|
||||
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
||||
import type { VWorldMeta } from "./B04_PreProcess_Api_Fetch";
|
||||
import type { Normalizer } from "./B04_PreProcess_UI_MapRender";
|
||||
import { glCameraOf } from "./logic/B04_PreProcess_UI_MapGl_Frame";
|
||||
|
||||
/** 옛 캔버스로 그림("1") — 처음 = GL. */
|
||||
const CANVAS_PREF_KEY = "aislo:pref:map.canvas";
|
||||
export const GL_TOGGLE_TITLE = "그래픽 카드 그림(바탕 겹을 GPU 로 · 끔 = 옛 캔버스 그림)";
|
||||
|
||||
export interface TmGl<K extends string> {
|
||||
gl: GlMap<K>;
|
||||
/** 지금 GL 로 그리나. */
|
||||
on: () => boolean;
|
||||
/** 켬끔 단추 — 취향을 적고 바꿈. */
|
||||
toggle: () => void;
|
||||
/** 화면을 따라감 — `toScreen` = 정규화 → 화면 px(돌림 포함 · 캔버스와 같은 식). */
|
||||
follow: (
|
||||
meta: VWorldMeta | null,
|
||||
normalizer: Normalizer | null,
|
||||
toScreen: (nx: number, ny: number) => [number, number],
|
||||
width: number,
|
||||
height: number,
|
||||
) => void;
|
||||
/** 화면 px → 지도 칸 안 px(칸 행렬 · 둘레 여유를 되돌림) — 칸 자르기(clip-path)용. */
|
||||
local: (x: number, y: number) => [number, number];
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
/** 지도 칸 둘레 여유(px · CSS `.ui-gl-map` inset) — 칸 행렬(늘림 0.4 %)이 화면 귀퉁이를 비우지 않게. */
|
||||
const GL_MARGIN_PX = 8;
|
||||
|
||||
/** 그림 · 선 겹을 GL 로(위성 조각 = `satelliteData`) — `parent` 맨 앞에 지도 칸 · `onChange` = 켬끔이 바뀜(다시 그림 · 처음 켬은 안 부름). */
|
||||
export function createTmGl<K extends string>(opts: {
|
||||
parent: HTMLElement;
|
||||
specs: () => GlSpec<K>[];
|
||||
themeNames: readonly string[];
|
||||
onChange: (on: boolean) => void;
|
||||
}): TmGl<K> {
|
||||
let active = false;
|
||||
/** 켜려는 중 · 켜 둠 — 못 켬 알림은 이때만. */
|
||||
let wanted = false;
|
||||
let size = "";
|
||||
let css: [number, number, number, number] = [1, 0, 0, 1];
|
||||
/** 처음 켬이 끝남 — 그 전(만드는 중)에는 `onChange` 를 안 부름(부르는 쪽 `on()` 으로 읽음). */
|
||||
let started = false;
|
||||
const gl = createGlMap<K>({
|
||||
specs: opts.specs,
|
||||
themeNames: opts.themeNames,
|
||||
onFail: (why) => {
|
||||
if (!wanted) return;
|
||||
showToast(`그래픽 카드 그림을 못 써 옛 그림으로 — ${why}`, "info");
|
||||
use(false);
|
||||
},
|
||||
});
|
||||
function use(want: boolean): void {
|
||||
wanted = want;
|
||||
active = want && gl.enable(opts.parent);
|
||||
if (!active) gl.enable(null);
|
||||
size = "";
|
||||
if (started) opts.onChange(active);
|
||||
}
|
||||
use(readByKey(CANVAS_PREF_KEY) !== "1");
|
||||
started = true;
|
||||
return {
|
||||
gl,
|
||||
on: () => active,
|
||||
toggle() {
|
||||
writeByKey(CANVAS_PREF_KEY, active ? "1" : "0");
|
||||
use(!active);
|
||||
},
|
||||
follow(meta, normalizer, toScreen, width, height) {
|
||||
if (!active || !meta || !normalizer || width <= 0 || height <= 0) return;
|
||||
const container = gl.container();
|
||||
const now = `${width}x${height}`;
|
||||
if (container && now !== size) {
|
||||
// 칸 = 화면(캔버스 정수 px) + 둘레 여유 — 가운데가 캔버스 가운데와 같게
|
||||
size = now;
|
||||
container.style.width = `${width + 2 * GL_MARGIN_PX}px`;
|
||||
container.style.height = `${height + 2 * GL_MARGIN_PX}px`;
|
||||
gl.resize();
|
||||
}
|
||||
const camera = glCameraOf(meta, normalizer, toScreen, width, height);
|
||||
if (!camera) return;
|
||||
gl.camera(camera.center, camera.zoom, camera.bearing);
|
||||
css = camera.css;
|
||||
if (!container) return;
|
||||
container.style.transform = `matrix(${css.join(", ")}, 0, 0)`;
|
||||
// 검증 창구 — 캔버스 자리(정규화 → 화면) 대 MapLibre 자리를 화면에서 잼
|
||||
Object.assign(container, {
|
||||
glCheck: { map: gl.map(), normalizer, toScreen, css, width, height },
|
||||
});
|
||||
},
|
||||
local(x, y) {
|
||||
const [a, b, c, d] = css;
|
||||
const det = a * d - b * c || 1;
|
||||
const [w, h] = size.split("x").map(Number);
|
||||
const dx = x - w / 2;
|
||||
const dy = y - h / 2;
|
||||
return [
|
||||
(d * dx - c * dy) / det + w / 2 + GL_MARGIN_PX,
|
||||
(a * dy - b * dx) / det + h / 2 + GL_MARGIN_PX,
|
||||
];
|
||||
},
|
||||
dispose: () => gl.enable(null),
|
||||
};
|
||||
}
|
||||
|
||||
const NOT_POINT = ["!=", ["geometry-type"], "Point"] as unknown as FilterSpecification;
|
||||
const IS_POINT = ["==", ["geometry-type"], "Point"] as unknown as FilterSpecification;
|
||||
|
||||
/** 위성 조각 자료(`setData("satellite", …)` · 프로젝트마다). */
|
||||
export const satelliteData = (projectId: string): GlRaster => ({
|
||||
raster: satelliteTileSource(projectId),
|
||||
});
|
||||
|
||||
/** 위성 · 그림 겹(조각 · 한 장). */
|
||||
export const rasterSpec = <K extends string>(key: K, source: string = key): GlSpec<K> =>
|
||||
({
|
||||
key,
|
||||
id: `${key}`,
|
||||
type: "raster",
|
||||
source,
|
||||
paint: { "raster-opacity": 1, "raster-fade-duration": 0 },
|
||||
}) as GlSpec<K>;
|
||||
|
||||
/** 선 겹(캔버스 `drawPreparedLayer` 꼴) — 선 + 점(점 = 지름 4 동그라미 · x = 표고점 X 글자). */
|
||||
export function lineSpecs<K extends string>(
|
||||
key: K,
|
||||
color: string | ExpressionSpecification,
|
||||
width: number,
|
||||
opts: {
|
||||
source?: string;
|
||||
marker?: "dot" | "x";
|
||||
filter?: unknown;
|
||||
opacity?: number;
|
||||
dash?: number[];
|
||||
} = {},
|
||||
): GlSpec<K>[] {
|
||||
const source = opts.source ?? key;
|
||||
const lineFilter = (
|
||||
opts.filter ? ["all", NOT_POINT, opts.filter] : NOT_POINT
|
||||
) as FilterSpecification;
|
||||
const specs: GlSpec<K>[] = [
|
||||
{
|
||||
key,
|
||||
id: `${key}-line`,
|
||||
type: "line",
|
||||
source,
|
||||
filter: lineFilter,
|
||||
layout: { "line-join": "round", "line-cap": "round" },
|
||||
paint: {
|
||||
"line-color": color,
|
||||
"line-width": width,
|
||||
...(opts.opacity !== undefined ? { "line-opacity": opts.opacity } : {}),
|
||||
...(opts.dash ? { "line-dasharray": opts.dash.map((px) => px / width) } : {}),
|
||||
},
|
||||
} as GlSpec<K>,
|
||||
];
|
||||
if (opts.marker === "x")
|
||||
specs.push({
|
||||
key,
|
||||
id: `${key}-point`,
|
||||
type: "symbol",
|
||||
source,
|
||||
filter: IS_POINT,
|
||||
layout: {
|
||||
"text-field": "×",
|
||||
"text-font": [GL_FONT],
|
||||
"text-size": 16,
|
||||
"text-allow-overlap": true,
|
||||
"text-ignore-placement": true,
|
||||
},
|
||||
paint: { "text-color": color },
|
||||
} as GlSpec<K>);
|
||||
else if (opts.marker === "dot")
|
||||
specs.push({
|
||||
key,
|
||||
id: `${key}-point`,
|
||||
type: "circle",
|
||||
source,
|
||||
filter: IS_POINT,
|
||||
paint: { "circle-radius": 2, "circle-color": color },
|
||||
} as GlSpec<K>);
|
||||
return specs;
|
||||
}
|
||||
|
||||
/** 지도 칸이 켰을 때 쓰는 바탕 그림(위성 한 장 · 백지도 · 혼합)의 네 귀퉁이 — 메타 상자 = 메르카토르 그림 네모. */
|
||||
export const metaCorners = (
|
||||
meta: VWorldMeta,
|
||||
): [[number, number], [number, number], [number, number], [number, number]] => [
|
||||
[meta.lon_min, meta.lat_max],
|
||||
[meta.lon_max, meta.lat_max],
|
||||
[meta.lon_max, meta.lat_min],
|
||||
[meta.lon_min, meta.lat_min],
|
||||
];
|
||||
@@ -7,6 +7,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
||||
import { fetchGisGeoJson } from "./B04_PreProcess_Api_Fetch";
|
||||
import type { GeoJsonCollection } from "./B04_PreProcess_UI_MapRender";
|
||||
@@ -99,6 +100,22 @@ const GIS_LAYER_COLOR_TOKENS: Record<GisLayer, [name: string, fallback: string]>
|
||||
도엽_옹벽석축: ["--map-sheet-wall", "#0f766e"],
|
||||
};
|
||||
|
||||
const GIS_LABEL_KEYS: Record<GisLayer, keyof typeof ui_locales> = {
|
||||
지적도: "B04_Surface_Map_Cadastral",
|
||||
행정구역_시군구: "B04_Surface_Map_Sigungu",
|
||||
행정구역_읍면동: "B04_Surface_Map_Eupmyeondong",
|
||||
등고선: "B04_Surface_Map_Contour",
|
||||
도엽_등고선: "B04_Surface_Map_SheetContour",
|
||||
도엽_하천중심선: "B04_Surface_Map_SheetStream",
|
||||
도엽_표고점: "B04_Surface_Map_SheetElevPoint",
|
||||
도엽_성절토: "B04_Surface_Map_SheetCutFill",
|
||||
도엽_옹벽석축: "B04_Surface_Map_SheetWall",
|
||||
};
|
||||
|
||||
/** 층 단추 글(지금 말). */
|
||||
export const gisLayerLabel = (layer: GisLayer): string =>
|
||||
ui_locales[GIS_LABEL_KEYS[layer]][currentLanguageIndex];
|
||||
|
||||
export const gisLayerColor = (layer: GisLayer): string =>
|
||||
themeColor(...GIS_LAYER_COLOR_TOKENS[layer]);
|
||||
|
||||
|
||||
@@ -22,9 +22,11 @@ import {
|
||||
GIS_LAYERS,
|
||||
fetchGisLayer,
|
||||
gisLayerColor,
|
||||
gisLayerLabel,
|
||||
type BackgroundLayer,
|
||||
type GisLayer,
|
||||
} from "./B04_PreProcess_UI_MapLayers";
|
||||
import { createSurfaceMapGl } from "./B04_PreProcess_UI_MapViewer_Gl";
|
||||
import { createFlowStrengthOverlay } from "./B04_PreProcess_UI_FlowStrength";
|
||||
import { createLayerToggle } from "./B04_PreProcess_UI_LayerToggle";
|
||||
import { createWatershedOverlay } from "./B04_PreProcess_UI_Watershed";
|
||||
@@ -220,17 +222,6 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
backgroundButtons.append(makeLayerButton(backgroundLabels[layer], activeBackgrounds, layer));
|
||||
});
|
||||
|
||||
const gisLabels: Record<GisLayer, string> = {
|
||||
지적도: L("B04_Surface_Map_Cadastral"),
|
||||
행정구역_시군구: L("B04_Surface_Map_Sigungu"),
|
||||
행정구역_읍면동: L("B04_Surface_Map_Eupmyeondong"),
|
||||
등고선: L("B04_Surface_Map_Contour"),
|
||||
도엽_등고선: L("B04_Surface_Map_SheetContour"),
|
||||
도엽_하천중심선: L("B04_Surface_Map_SheetStream"),
|
||||
도엽_표고점: L("B04_Surface_Map_SheetElevPoint"),
|
||||
도엽_성절토: L("B04_Surface_Map_SheetCutFill"),
|
||||
도엽_옹벽석축: L("B04_Surface_Map_SheetWall"),
|
||||
};
|
||||
// 계획선 토글 — 도엽 레이어와 같은 양식으로 레이어 줄 맨 앞에 둔다. 색은 B05 배수유역도와
|
||||
// 같은 주황(정의처: MapRender). 계획노선을 아직 올리지 않았으면 버튼이 눌리지 않는다.
|
||||
const routeToggle = createLayerToggle({
|
||||
@@ -246,7 +237,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
|
||||
GIS_LAYERS.forEach((layer) => {
|
||||
gisButtons.append(
|
||||
makeLayerButton(gisLabels[layer], activeGisLayers, layer, gisLayerColor(layer)),
|
||||
makeLayerButton(gisLayerLabel(layer), activeGisLayers, layer, gisLayerColor(layer)),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -260,6 +251,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
},
|
||||
});
|
||||
gisButtons.append(contourLabelToggle.root);
|
||||
const surfaceGl = createSurfaceMapGl(viewport, backgroundButtons, () => syncLayerVisibility());
|
||||
|
||||
// 배수유역 분석 오버레이 — 계산은 백엔드가 하고 여기서는 겹쳐 그리기만 한다.
|
||||
// 상태 문구는 오버레이 전용 줄에 쓴다 — 지도 자체 상태(레이어 로딩)와 같은 칸을 쓰면
|
||||
@@ -317,8 +309,9 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
|
||||
function syncLayerVisibility(): void {
|
||||
backgroundImages.forEach((image, layer) => {
|
||||
image.hidden = !activeBackgrounds.has(layer);
|
||||
image.hidden = !activeBackgrounds.has(layer) || surfaceGl.on();
|
||||
});
|
||||
surfaceGl.sync(activeBackgrounds, activeGisLayers);
|
||||
activeGisLayers.forEach((layer) => void loadLayerOnDemand(layer));
|
||||
empty.hidden = activeBackgrounds.size > 0 || activeGisLayers.size > 0;
|
||||
scheduleDraw();
|
||||
@@ -389,7 +382,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
const mapRect = computeMapRect(meta, width, height);
|
||||
const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect };
|
||||
DRAW_ORDER.forEach((layer) => {
|
||||
if (!activeGisLayers.has(layer)) return;
|
||||
if (!activeGisLayers.has(layer) || surfaceGl.on()) return;
|
||||
const prepared = preparedLayers.get(layer);
|
||||
if (!prepared) return;
|
||||
context.lineWidth = isContourLayer(layer) ? 0.7 : 1.5;
|
||||
@@ -430,6 +423,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
// 유입 집중점(집중유역)은 맨 마지막 — 켰을 때 무엇에도 가리지 않아야 고를 수 있다.
|
||||
flowStrength.drawTop(context, normalizer, view);
|
||||
updateImageTransform(view);
|
||||
surfaceGl.follow(meta, normalizer, view);
|
||||
drawScaleBar(mapRect);
|
||||
}
|
||||
|
||||
@@ -449,6 +443,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
const data = await fetchGisLayer(projectId, layer);
|
||||
if (!data || projectId !== currentProjectId || !normalizer) return;
|
||||
preparedLayers.set(layer, prepareLayer(data, normalizer, CONTOUR_LABEL_KEYS[layer]));
|
||||
surfaceGl.setLayer(layer, data);
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
@@ -458,6 +453,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
const sequence = ++loadSequence;
|
||||
backgroundImages.forEach((image) => image.removeAttribute("src"));
|
||||
meta = null;
|
||||
surfaceGl.setMeta(null, null);
|
||||
preparedLayers.clear();
|
||||
routeLayer = null;
|
||||
routePoints = [];
|
||||
@@ -487,6 +483,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
meta = nextMeta;
|
||||
// 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다.
|
||||
normalizer = createTmNormalizer(nextMeta);
|
||||
surfaceGl.setMeta(projectId, nextMeta);
|
||||
routeLayer =
|
||||
planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null;
|
||||
routePoints = planned.points;
|
||||
@@ -502,6 +499,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
if (!data) return;
|
||||
featureCount += data.features?.length ?? 0;
|
||||
preparedLayers.set(layer, prepareLayer(data, normalizer!, CONTOUR_LABEL_KEYS[layer]));
|
||||
surfaceGl.setLayer(layer, data);
|
||||
});
|
||||
BACKGROUND_LAYERS.forEach((layer) => {
|
||||
// 주소에 시각을 붙이면 브라우저가 매번 다시 받는다. 서버가 ETag를 주므로 그대로 쓴다.
|
||||
@@ -688,6 +686,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
frameHandle = 0;
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
surfaceGl.dispose();
|
||||
flowStrength.dispose();
|
||||
detailBasins.dispose();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/* =============================================================================
|
||||
* B04_PreProcess_UI_MapViewer_Gl.ts
|
||||
* B04 하단 지도 GL 바탕(PLAN 589-2) — 백지도 · 혼합(한 장 그림) · 위성(조각 · 줌인 선명) · 도엽 · 지적 · 행정 ·
|
||||
* 등고선 겹을 MapLibre 로. 등고 글 · 배수유역 · 계획선 · 측점 · 흐름 강도 · 세부유역 · 관 = 위 캔버스 그대로.
|
||||
* 지도 본체(`_MapViewer`)는 켬끔 · 자료 · 따라가기만 부름(700 줄).
|
||||
* ========================================================================== */
|
||||
|
||||
import { getVWorldMapUrl, type VWorldMeta } from "./B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
BACKGROUND_LAYERS,
|
||||
GIS_LAYERS,
|
||||
gisLayerColor,
|
||||
type BackgroundLayer,
|
||||
type GisLayer,
|
||||
} from "./B04_PreProcess_UI_MapLayers";
|
||||
import { createLayerToggle } from "./B04_PreProcess_UI_LayerToggle";
|
||||
import {
|
||||
createTmGl,
|
||||
GL_TOGGLE_TITLE,
|
||||
lineSpecs,
|
||||
metaCorners,
|
||||
rasterSpec,
|
||||
satelliteData,
|
||||
} from "./B04_PreProcess_UI_MapGl";
|
||||
import {
|
||||
normalizedToScreen,
|
||||
type GeoJsonCollection,
|
||||
type Normalizer,
|
||||
type ViewState,
|
||||
} from "./B04_PreProcess_UI_MapRender";
|
||||
|
||||
type Key = BackgroundLayer | GisLayer;
|
||||
const isContour = (layer: GisLayer): boolean => layer === "등고선" || layer === "도엽_등고선";
|
||||
/** 캔버스와 같은 차례 — 등고선이 맨 밑. */
|
||||
const ORDER = [...GIS_LAYERS].sort((a, b) => (isContour(a) ? -1 : isContour(b) ? 1 : 0));
|
||||
|
||||
export interface SurfaceMapGl {
|
||||
/** 지금 GL 로 그리나 — 참이면 캔버스는 바탕 겹을 쉼 · 그림(img)은 숨김. */
|
||||
on: () => boolean;
|
||||
/** 켠 겹. */
|
||||
sync: (backgrounds: ReadonlySet<BackgroundLayer>, layers: ReadonlySet<GisLayer>) => void;
|
||||
/** 받은 겹(원본 경위도 GeoJSON) — null = 비움. */
|
||||
setLayer: (layer: GisLayer, data: GeoJsonCollection | null) => void;
|
||||
/** 프로젝트 · 메타 — 그림 · 조각 주소(null = 다 비움). */
|
||||
setMeta: (projectId: string | null, meta: VWorldMeta | null) => void;
|
||||
follow: (meta: VWorldMeta | null, normalizer: Normalizer | null, view: ViewState) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
/** `buttons` = 켬끔 칩 자리 · `onChange` = 켬끔이 바뀜(그림 숨김 · 다시 그림). */
|
||||
export function createSurfaceMapGl(
|
||||
viewport: HTMLElement,
|
||||
buttons: HTMLElement,
|
||||
onChange: () => void,
|
||||
): SurfaceMapGl {
|
||||
const toggle = createLayerToggle({
|
||||
label: "GPU",
|
||||
title: GL_TOGGLE_TITLE,
|
||||
onChange: () => tm.toggle(),
|
||||
});
|
||||
toggle.root.dataset.layer = "gl";
|
||||
buttons.append(toggle.root);
|
||||
const tm = createTmGl<Key>({
|
||||
parent: viewport,
|
||||
specs: () => [
|
||||
...BACKGROUND_LAYERS.map((layer) => rasterSpec<Key>(layer)),
|
||||
...ORDER.flatMap((layer) =>
|
||||
lineSpecs<Key>(layer, gisLayerColor(layer), isContour(layer) ? 0.7 : 1.5, {
|
||||
marker: layer === "도엽_표고점" ? "x" : "dot",
|
||||
}),
|
||||
),
|
||||
],
|
||||
themeNames: ["--color-surface", "--map-cadastral", "--map-sheet-contour", "--map-sheet-stream"],
|
||||
onChange: (on) => {
|
||||
toggle.set(on);
|
||||
onChange();
|
||||
},
|
||||
});
|
||||
toggle.set(tm.on());
|
||||
return {
|
||||
on: tm.on,
|
||||
sync(backgrounds, layers) {
|
||||
for (const layer of BACKGROUND_LAYERS) tm.gl.setVisible(layer, backgrounds.has(layer));
|
||||
for (const layer of GIS_LAYERS) tm.gl.setVisible(layer, layers.has(layer));
|
||||
},
|
||||
setLayer: (layer, data) => tm.gl.setData(layer, data),
|
||||
setMeta(projectId, meta) {
|
||||
tm.gl.setData("satellite", projectId ? satelliteData(projectId) : null);
|
||||
for (const layer of ["white", "hybrid"] as const)
|
||||
tm.gl.setData(
|
||||
layer,
|
||||
projectId && meta
|
||||
? {
|
||||
url: location.origin + getVWorldMapUrl(projectId, layer),
|
||||
corners: metaCorners(meta),
|
||||
}
|
||||
: null,
|
||||
);
|
||||
if (!meta) for (const layer of GIS_LAYERS) tm.gl.setData(layer, null);
|
||||
},
|
||||
follow: (meta, normalizer, view) =>
|
||||
tm.follow(
|
||||
meta,
|
||||
normalizer,
|
||||
(nx, ny) => normalizedToScreen(view, nx, ny),
|
||||
view.width,
|
||||
view.height,
|
||||
),
|
||||
dispose: tm.dispose,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/* =============================================================================
|
||||
* B04_PreProcess_UI_MapGl_Frame.ts
|
||||
* GL 바탕이 TM 주인 화면을 따라가는 셈(PLAN 589-2) — B04 하단 지도 · B05 평면 노선 편집 · 배수유역도.
|
||||
* 카메라 = 위성 그림 행렬(589-1 `imageScreenMatrix`)과 같은 길을 화면 가운데에서 1차로 맞춘 웹 메르카토르
|
||||
* 가운데 · 줌 · 돌림(MapLibre 세계 512 px · 돌림 = 동쪽이 화면에서 도는 각의 반대).
|
||||
* TM 겹 → 경위도 = 고를 칸 `toNorm` 되돌림(고정 기울기 되풀이 · 격자가 거의 1차라 몇 번에 1 mm 안).
|
||||
* 순수 셈 — 화면 · fetch 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { VWorldMeta } from "../B04_PreProcess_Api_Fetch";
|
||||
import type { Normalizer, PreparedLayer } from "../B04_PreProcess_UI_MapRender";
|
||||
import { imageScreenMatrix, normOfLonLat } from "./B04_PreProcess_UI_MapRender_Prepare";
|
||||
|
||||
export interface GlCamera {
|
||||
center: [number, number];
|
||||
zoom: number;
|
||||
bearing: number;
|
||||
/**
|
||||
* 지도 칸 CSS 행렬 `[a, b, c, d]`(칸 가운데 축) — MapLibre 는 닮음꼴(확대 · 돌림)만 그리므로 남는 늘림
|
||||
* (웹 메르카토르 px 가로세로가 땅과 0.4 % 다름 · 1 − M/N)을 칸에 줌. 늘이기만(고윳값 ≥ 1) — 칸 둘레 여유로 빈틈 없음.
|
||||
*/
|
||||
css: [number, number, number, number];
|
||||
}
|
||||
|
||||
const IMAGE = 2 ** 20;
|
||||
const latOfMercator = (m: number): number => (Math.atan(Math.exp(m)) * 360) / Math.PI - 90;
|
||||
const mercatorOf = (lat: number): number => Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360));
|
||||
|
||||
/** 지금 화면(정규화 → 화면 px · 돌림 포함)에 맞는 GL 카메라 — 못 셈이면 null. */
|
||||
export function glCameraOf(
|
||||
meta: VWorldMeta,
|
||||
normalizer: Normalizer,
|
||||
toScreen: (nx: number, ny: number) => [number, number],
|
||||
width: number,
|
||||
height: number,
|
||||
): GlCamera | null {
|
||||
// 가상 그림(한 변 IMAGE px) — 행렬 셈의 미분 간격(0.5 px)이 상자 백만 분의 일이 되게
|
||||
const [a, b, c, d, e, f] = imageScreenMatrix(
|
||||
meta,
|
||||
normalizer,
|
||||
IMAGE,
|
||||
IMAGE,
|
||||
toScreen,
|
||||
width / 2,
|
||||
height / 2,
|
||||
);
|
||||
const det = a * d - b * c;
|
||||
const lonRange = meta.lon_max - meta.lon_min;
|
||||
const mTop = mercatorOf(meta.lat_max);
|
||||
const mSpan = mTop - mercatorOf(meta.lat_min);
|
||||
if (!Number.isFinite(det) || Math.abs(det) < 1e-18 || !lonRange || !mSpan) return null;
|
||||
const ex = width / 2 - e;
|
||||
const ey = height / 2 - f;
|
||||
const u = (d * ex - c * ey) / det / IMAGE;
|
||||
const v = (a * ey - b * ex) / det / IMAGE;
|
||||
// 화면 px / 세계 px(줌 0 = 512) — 열 = 세계 동 · 남
|
||||
const ku = (lonRange / IMAGE / 360) * 512;
|
||||
const kv = (mSpan / IMAGE / (2 * Math.PI)) * 512;
|
||||
const m = [a / ku, b / ku, c / kv, d / kv];
|
||||
// 극 분해 — 돌림 R · 대칭 늘림 P(작은 늘림 = 1 이 되게 줌을 고름)
|
||||
const turn = Math.atan2(m[1] - m[2], m[0] + m[3]);
|
||||
const cos = Math.cos(turn);
|
||||
const sin = Math.sin(turn);
|
||||
// P = Rᵀ M
|
||||
const p = [
|
||||
cos * m[0] + sin * m[1],
|
||||
-sin * m[0] + cos * m[1],
|
||||
cos * m[2] + sin * m[3],
|
||||
-sin * m[2] + cos * m[3],
|
||||
];
|
||||
const trace = (p[0] + p[3]) / 2;
|
||||
const least = trace - Math.hypot((p[0] - p[3]) / 2, (p[1] + p[2]) / 2);
|
||||
if (!(least > 0)) return null;
|
||||
// 칸 행렬 T = M Rᵀ / 작은 늘림 = R P Rᵀ / 작은 늘림
|
||||
const css: [number, number, number, number] = [
|
||||
(m[0] * cos - m[2] * sin) / least,
|
||||
(m[1] * cos - m[3] * sin) / least,
|
||||
(m[0] * sin + m[2] * cos) / least,
|
||||
(m[1] * sin + m[3] * cos) / least,
|
||||
];
|
||||
return {
|
||||
center: [meta.lon_min + u * lonRange, latOfMercator(mTop - v * mSpan)],
|
||||
zoom: Math.log2(least),
|
||||
bearing: -(turn * 180) / Math.PI,
|
||||
css,
|
||||
};
|
||||
}
|
||||
|
||||
/** 정규화(TM 자리) → 경위도 — `normOfLonLat` 되돌림. 상자 기울기로 되풀이(1차에 가까워 다섯 번이면 넉넉). */
|
||||
export function lonLatOfNormFn(
|
||||
normalizer: Normalizer,
|
||||
): (nx: number, ny: number) => [number, number] {
|
||||
const { lonMin, latMin, lonRange, latRange } = normalizer;
|
||||
const latMax = latMin + latRange;
|
||||
// 상자 기울기(정규화 1 당 경위도) — 같은 네모 식이면 곧바로 맞음
|
||||
const [x00, y00] = normOfLonLat(normalizer, lonMin, latMax);
|
||||
const [x10, y10] = normOfLonLat(normalizer, lonMin + lonRange, latMax);
|
||||
const [x01, y01] = normOfLonLat(normalizer, lonMin, latMin);
|
||||
const ja = (x10 - x00) / lonRange;
|
||||
const jb = (y10 - y00) / lonRange;
|
||||
const jc = (x01 - x00) / -latRange;
|
||||
const jd = (y01 - y00) / -latRange;
|
||||
const det = ja * jd - jb * jc || 1;
|
||||
return (nx, ny) => {
|
||||
let lon = lonMin + nx * lonRange;
|
||||
let lat = latMax - ny * latRange;
|
||||
for (let step = 0; step < 5; step += 1) {
|
||||
const [x, y] = normOfLonLat(normalizer, lon, lat);
|
||||
const dx = nx - x;
|
||||
const dy = ny - y;
|
||||
lon += (jd * dx - jc * dy) / det;
|
||||
lat += (ja * dy - jb * dx) / det;
|
||||
if (Math.abs(dx) + Math.abs(dy) < 1e-12) break;
|
||||
}
|
||||
return [lon, lat];
|
||||
};
|
||||
}
|
||||
|
||||
type LonLat = [number, number];
|
||||
type Box = readonly [minX: number, minY: number, maxX: number, maxY: number];
|
||||
|
||||
/** 선분 [p, q] 를 상자에 자름(리앙-바스키) — 밖이면 null. */
|
||||
function clipSegment(
|
||||
px: number,
|
||||
py: number,
|
||||
qx: number,
|
||||
qy: number,
|
||||
box: Box,
|
||||
): [number, number, number, number] | null {
|
||||
let t0 = 0;
|
||||
let t1 = 1;
|
||||
const dx = qx - px;
|
||||
const dy = qy - py;
|
||||
const edges: [number, number][] = [
|
||||
[-dx, px - box[0]],
|
||||
[dx, box[2] - px],
|
||||
[-dy, py - box[1]],
|
||||
[dy, box[3] - py],
|
||||
];
|
||||
for (const [p, q] of edges) {
|
||||
if (p === 0) {
|
||||
if (q < 0) return null;
|
||||
continue;
|
||||
}
|
||||
const r = q / p;
|
||||
if (p < 0) t0 = Math.max(t0, r);
|
||||
else t1 = Math.min(t1, r);
|
||||
if (t0 > t1) return null;
|
||||
}
|
||||
return [px + t0 * dx, py + t0 * dy, px + t1 * dx, py + t1 * dy];
|
||||
}
|
||||
|
||||
/** 정규화 점렬 → 상자 안 조각들(상자 없으면 통째). */
|
||||
function runsOf(coords: ArrayLike<number>, closed: boolean, box?: Box): number[][] {
|
||||
const n = coords.length / 2;
|
||||
const points: number[] = Array.from(coords);
|
||||
if (closed && n > 2) points.push(coords[0], coords[1]);
|
||||
if (!box) return [points];
|
||||
const runs: number[][] = [];
|
||||
let run: number[] = [];
|
||||
for (let i = 0; i + 3 < points.length; i += 2) {
|
||||
const cut = clipSegment(points[i], points[i + 1], points[i + 2], points[i + 3], box);
|
||||
if (!cut) {
|
||||
if (run.length >= 4) runs.push(run);
|
||||
run = [];
|
||||
continue;
|
||||
}
|
||||
const [ax, ay, bx, by] = cut;
|
||||
const last = run.length;
|
||||
if (!last || run[last - 2] !== ax || run[last - 1] !== ay) {
|
||||
if (last >= 4) runs.push(run);
|
||||
run = [ax, ay];
|
||||
}
|
||||
run.push(bx, by);
|
||||
}
|
||||
if (run.length >= 4) runs.push(run);
|
||||
return runs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 준비한 겹(정규화 · TM 자리) → GL 경위도 GeoJSON. 선 = LineString(닫힌 것은 첫 점 다시) · 점 = MultiPoint ·
|
||||
* 성질 `level` = 표고(없으면 뺌). `box`(정규화) = 그 안만(B05 LAS 등고선이 있는 데까지 · 0-9 ⑮).
|
||||
*/
|
||||
export function preparedToGeoJson(
|
||||
layer: PreparedLayer,
|
||||
toLonLat: (nx: number, ny: number) => LonLat,
|
||||
box?: Box,
|
||||
): { type: "FeatureCollection"; features: unknown[] } {
|
||||
const features: unknown[] = [];
|
||||
for (const feature of layer.features) {
|
||||
const properties = feature.labelValue === null ? {} : { level: feature.labelValue };
|
||||
for (const part of feature.parts) {
|
||||
if (feature.kind === "point") {
|
||||
const coordinates: LonLat[] = [];
|
||||
for (let i = 0; i + 1 < part.coords.length; i += 2) {
|
||||
const x = part.coords[i];
|
||||
const y = part.coords[i + 1];
|
||||
if (box && (x < box[0] || x > box[2] || y < box[1] || y > box[3])) continue;
|
||||
coordinates.push(toLonLat(x, y));
|
||||
}
|
||||
if (coordinates.length)
|
||||
features.push({
|
||||
type: "Feature",
|
||||
properties,
|
||||
geometry: { type: "MultiPoint", coordinates },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const run of runsOf(part.coords, part.closed, box)) {
|
||||
if (run.length < 4) continue;
|
||||
const coordinates: LonLat[] = [];
|
||||
for (let i = 0; i < run.length; i += 2) coordinates.push(toLonLat(run[i], run[i + 1]));
|
||||
features.push({
|
||||
type: "Feature",
|
||||
properties,
|
||||
geometry: { type: "LineString", coordinates },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { type: "FeatureCollection", features };
|
||||
}
|
||||
|
||||
/** 점이 고리 안인가(짝홀). */
|
||||
function inside(x: number, y: number, ring: ReadonlyArray<readonly [number, number]>): boolean {
|
||||
let hit = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const [xi, yi] = ring[i];
|
||||
const [xj, yj] = ring[j];
|
||||
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit;
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 고리 목록(조각 · 구멍 섞임 · 캔버스 `evenodd`) → GeoJSON 다각형들 — 몇 겹 안에 드나(짝 = 바깥 · 홀 = 구멍)로
|
||||
* 구멍을 제 바깥에 붙임. 같은 칠 = 캔버스 짝홀 채움.
|
||||
*/
|
||||
export function ringsToPolygons(
|
||||
rings: ReadonlyArray<ReadonlyArray<readonly [number, number]>>,
|
||||
): LonLat[][][] {
|
||||
const usable = rings.filter((ring) => ring.length >= 3);
|
||||
const depth = usable.map((ring, i) =>
|
||||
usable.reduce(
|
||||
(count, other, j) => count + (i !== j && inside(ring[0][0], ring[0][1], other) ? 1 : 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
const polygons: LonLat[][][] = [];
|
||||
const owner = new Map<number, number>();
|
||||
usable.forEach((ring, i) => {
|
||||
if (depth[i] % 2) return;
|
||||
owner.set(i, polygons.length);
|
||||
polygons.push([ring.map(([x, y]) => [x, y] as LonLat)]);
|
||||
});
|
||||
usable.forEach((ring, i) => {
|
||||
if (!(depth[i] % 2)) return;
|
||||
// 제 바깥 = 저를 품은 고리 중 한 겹 얕은 것
|
||||
const parent = usable.findIndex(
|
||||
(other, j) => depth[j] === depth[i] - 1 && inside(ring[0][0], ring[0][1], other),
|
||||
);
|
||||
const at = owner.get(parent);
|
||||
if (at !== undefined) polygons[at].push(ring.map(([x, y]) => [x, y] as LonLat));
|
||||
});
|
||||
return polygons;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Gl.ts
|
||||
* 배수유역도 GL 바탕(PLAN 589-2) — 위성(조각 · 줌인 선명) · 세부유역 채움 · 분수령 · 도엽(등고선 · 하천)을
|
||||
* MapLibre 로(캔버스와 같은 차례 · 색). 유역 번호 · 상류 세류 · 노선 · 관 · 강도 · 화살표 · 띠 · 측점 =
|
||||
* 위 캔버스 그대로 · 고르기 판정 그대로. 판(`_Drainage_Panel`)은 만들기 · 자료 · 그리기 때 부름만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createToggle } from "@ui/ui_template_toggle";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import {
|
||||
createTmGl,
|
||||
GL_TOGGLE_TITLE,
|
||||
lineSpecs,
|
||||
rasterSpec,
|
||||
satelliteData,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapGl";
|
||||
import {
|
||||
normalizedToScreen,
|
||||
type GeoJsonCollection,
|
||||
type ViewState,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import { ringsToPolygons } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapGl_Frame";
|
||||
import {
|
||||
basinColor,
|
||||
DRAINAGE_LAYERS,
|
||||
layerColor,
|
||||
type DrainageLayer,
|
||||
} from "./B05_Profile_UI_Drainage_Parts";
|
||||
import type { GlSpec } from "@ui/ui_template_gl_map";
|
||||
import type { DrainageScene } from "./B05_Profile_UI_Drainage_Render";
|
||||
|
||||
type Key = "satellite" | "basins" | "ridge" | DrainageLayer;
|
||||
|
||||
export interface DrainageGl {
|
||||
/** 그리기마다 — 켜져 있으면 바탕 겹을 맞추고 참(캔버스는 그 겹을 쉼). */
|
||||
draw: (
|
||||
view: ViewState,
|
||||
scene: Pick<
|
||||
DrainageScene,
|
||||
"meta" | "normalizer" | "basins" | "selectedBasin" | "mainBoundary" | "activeLayers"
|
||||
>,
|
||||
satellite: boolean,
|
||||
) => boolean;
|
||||
setLayer: (layer: DrainageLayer, data: GeoJsonCollection | null) => void;
|
||||
setProject: (projectId: string | null) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export function createDrainageGl(
|
||||
viewport: HTMLElement,
|
||||
buttons: HTMLElement,
|
||||
backgroundImage: HTMLImageElement,
|
||||
redraw: () => void,
|
||||
): DrainageGl {
|
||||
const toggle = createToggle({
|
||||
kind: "chip",
|
||||
label: "GPU",
|
||||
size: 20,
|
||||
onChange: () => tm.toggle(),
|
||||
});
|
||||
toggle.root.title = GL_TOGGLE_TITLE;
|
||||
toggle.root.dataset.layer = "gl";
|
||||
toggle.root.classList.add("b05-drainage__layer-button");
|
||||
buttons.append(toggle.root);
|
||||
const mark = (on: boolean): void => {
|
||||
toggle.set(on);
|
||||
toggle.root.classList.toggle("is-active", on);
|
||||
};
|
||||
const tm = createTmGl<Key>({
|
||||
parent: viewport,
|
||||
specs: () => [
|
||||
rasterSpec<Key>("satellite"),
|
||||
{
|
||||
key: "basins",
|
||||
id: "basins",
|
||||
type: "fill",
|
||||
source: "basins",
|
||||
paint: { "fill-color": ["get", "_color"] },
|
||||
} as GlSpec<Key>,
|
||||
...lineSpecs<Key>("basins", ["get", "_color"], 1.6),
|
||||
...lineSpecs<Key>("ridge", themeColor("--map-basin-outline", "#92400e"), 1.8, {
|
||||
dash: [7, 4],
|
||||
}),
|
||||
...DRAINAGE_LAYERS.flatMap((layer) =>
|
||||
lineSpecs<Key>(layer, layerColor(layer), layer === "도엽_등고선" ? 0.7 : 1.5, {
|
||||
marker: "dot",
|
||||
}),
|
||||
),
|
||||
],
|
||||
themeNames: ["--color-surface", "--map-basin-outline", "--map-sheet-contour", "--map-basin-1"],
|
||||
onChange: (on) => {
|
||||
mark(on);
|
||||
redraw();
|
||||
},
|
||||
});
|
||||
mark(tm.on());
|
||||
let shown: unknown[] = [];
|
||||
|
||||
return {
|
||||
draw(view, frame, satellite) {
|
||||
backgroundImage.hidden = !satellite || tm.on();
|
||||
if (!tm.on()) return false;
|
||||
const { gl } = tm;
|
||||
gl.setVisible("satellite", satellite);
|
||||
gl.setVisible("basins", true);
|
||||
gl.setVisible("ridge", true);
|
||||
for (const layer of DRAINAGE_LAYERS) gl.setVisible(layer, frame.activeLayers.has(layer));
|
||||
const key = [
|
||||
frame.basins,
|
||||
frame.selectedBasin,
|
||||
frame.mainBoundary,
|
||||
themeColor("--map-basin-1", ""),
|
||||
];
|
||||
if (key.some((value, index) => value !== shown[index])) {
|
||||
shown = key;
|
||||
gl.setData("basins", {
|
||||
features: frame.basins.map((basin) => {
|
||||
const color = basinColor(basin.index);
|
||||
const rings = basin.polygon_rings_lonlat?.length
|
||||
? basin.polygon_rings_lonlat
|
||||
: [basin.polygon_lonlat];
|
||||
return {
|
||||
type: "Feature",
|
||||
properties: {
|
||||
// 하나를 고르면 나머지는 옅게(캔버스와 같은 셈)
|
||||
_color:
|
||||
frame.selectedBasin === null || frame.selectedBasin === basin.index
|
||||
? color
|
||||
: color.replace(/0\.45\)$/, "0.18)"),
|
||||
},
|
||||
geometry: { type: "MultiPolygon", coordinates: ringsToPolygons(rings) },
|
||||
};
|
||||
}),
|
||||
});
|
||||
const ring = frame.mainBoundary;
|
||||
gl.setData(
|
||||
"ridge",
|
||||
ring.length > 2
|
||||
? {
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: { type: "LineString", coordinates: [...ring, ring[0]] },
|
||||
},
|
||||
],
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
tm.follow(
|
||||
frame.meta,
|
||||
frame.normalizer,
|
||||
(nx, ny) => normalizedToScreen(view, nx, ny),
|
||||
view.width,
|
||||
view.height,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
setLayer: (layer, data) => tm.gl.setData(layer, data),
|
||||
setProject: (projectId) =>
|
||||
tm.gl.setData("satellite", projectId ? satelliteData(projectId) : null),
|
||||
dispose: tm.dispose,
|
||||
};
|
||||
}
|
||||
@@ -42,7 +42,8 @@ import { createDrainageLive } from "./B05_Profile_UI_Drainage_Live";
|
||||
import { createDrainagePropose } from "./B05_Profile_UI_Drainage_Propose";
|
||||
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
|
||||
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
|
||||
import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render";
|
||||
import { drawDrainageScene, type DrainageScene } from "./B05_Profile_UI_Drainage_Render";
|
||||
import { createDrainageGl } from "./B05_Profile_UI_Drainage_Gl";
|
||||
import { computeZoomOutLimit } from "./B05_Profile_UI_Drainage_ZoomRange";
|
||||
import { mountDrainageShow } from "./B05_Profile_UI_Drainage_Show";
|
||||
import {
|
||||
@@ -188,6 +189,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
});
|
||||
|
||||
backgroundImage.addEventListener("load", () => scheduleDraw());
|
||||
const drainGl = createDrainageGl(viewport, layerButtons, backgroundImage, () => scheduleDraw());
|
||||
|
||||
function draw(): void {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
@@ -207,7 +209,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
context.clearRect(0, 0, width, height);
|
||||
const mapRect: MapRect = computeMapRect(meta, width, height);
|
||||
const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect };
|
||||
drawDrainageScene(context, view, {
|
||||
const scene: DrainageScene = {
|
||||
meta,
|
||||
normalizer,
|
||||
basins,
|
||||
@@ -233,7 +235,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
markedChainage,
|
||||
stationIntervalM: MAP_STATION_INTERVAL_M,
|
||||
intervalSpans,
|
||||
});
|
||||
};
|
||||
scene.glBase = drainGl.draw(view, scene, show.satellite);
|
||||
drawDrainageScene(context, view, scene);
|
||||
placeMapImage(backgroundImage, meta, normalizer, view);
|
||||
}
|
||||
|
||||
@@ -460,6 +464,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
const sequence = ++loadSequence;
|
||||
meta = null;
|
||||
preparedLayers.clear();
|
||||
drainGl.setProject(activeProjectId);
|
||||
DRAINAGE_LAYERS.forEach((layer) => drainGl.setLayer(layer, null));
|
||||
backgroundImage.removeAttribute("src");
|
||||
status.hidden = false;
|
||||
status.textContent = L("B05_Drainage_Status_LoadingBase");
|
||||
@@ -476,6 +482,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
if (!data) return;
|
||||
featureCount += data.features?.length ?? 0;
|
||||
preparedLayers.set(layer, prepareLayer(data, normalizer!));
|
||||
drainGl.setLayer(layer, data);
|
||||
});
|
||||
// 주소에 시각을 붙이면 브라우저가 매번 다시 받는다. 서버가 ETag를 주므로 그대로 쓴다.
|
||||
backgroundImage.src = getVWorldMapUrl(activeProjectId, "satellite");
|
||||
@@ -676,6 +683,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
frameHandle = 0;
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
drainGl.dispose();
|
||||
widthResizer.dispose();
|
||||
},
|
||||
rehome: () => resizeObserver.rehome(),
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface DrainageScene {
|
||||
stationIntervalM: number;
|
||||
/** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). 없으면 빈 배열. */
|
||||
intervalSpans: ReadonlyArray<RouteSpanBand>;
|
||||
/** GL 바탕이 유역 채움 · 분수령 · 도엽을 그림(589-2) — 캔버스는 그 겹을 쉼(번호는 그대로). */
|
||||
glBase?: boolean;
|
||||
}
|
||||
|
||||
export function drawDrainageScene(
|
||||
@@ -92,25 +94,26 @@ export function drawDrainageScene(
|
||||
label: String(basin.index),
|
||||
});
|
||||
}
|
||||
drawFilledRing(
|
||||
context,
|
||||
{ ring: basin.polygon_lonlat, rings: basin.polygon_rings_lonlat },
|
||||
normalizer,
|
||||
view,
|
||||
// 하나를 고르면 나머지는 옅게 물러난다.
|
||||
scene.selectedBasin === null || scene.selectedBasin === basin.index
|
||||
? color
|
||||
: color.replace(/0\.45\)$/, "0.18)"),
|
||||
);
|
||||
if (!scene.glBase)
|
||||
drawFilledRing(
|
||||
context,
|
||||
{ ring: basin.polygon_lonlat, rings: basin.polygon_rings_lonlat },
|
||||
normalizer,
|
||||
view,
|
||||
// 하나를 고르면 나머지는 옅게 물러난다.
|
||||
scene.selectedBasin === null || scene.selectedBasin === basin.index
|
||||
? color
|
||||
: color.replace(/0\.45\)$/, "0.18)"),
|
||||
);
|
||||
});
|
||||
// 전체 유역 외곽선 = 분수령(능선). 해석 결과를 그대로 그린다 — 손으로 고치지 않는다.
|
||||
if (scene.mainBoundary.length > 2) {
|
||||
if (!scene.glBase && scene.mainBoundary.length > 2) {
|
||||
drawRidgeRing(context, scene.mainBoundary as Array<[number, number]>, normalizer, view);
|
||||
}
|
||||
}
|
||||
// 등고선을 얇게 깔고 세류를 그 위에, 노선을 맨 위에 둔다.
|
||||
DRAINAGE_LAYERS.forEach((layer) => {
|
||||
if (!scene.activeLayers.has(layer)) return;
|
||||
if (scene.glBase || !scene.activeLayers.has(layer)) return;
|
||||
const prepared = scene.preparedLayers.get(layer);
|
||||
if (!prepared) return;
|
||||
context.lineWidth = layer === "도엽_등고선" ? 0.7 : 1.5;
|
||||
|
||||
@@ -8,6 +8,8 @@ import { mountDrainageToggles, type DrainageLayer } from "./B05_Profile_UI_Drain
|
||||
|
||||
/** 지금 켜진 표시 — 그리기가 읽음(토글이 고침). */
|
||||
export interface DrainageShow {
|
||||
/** 위성 — 기본 꺼짐(그림 숨김은 그리기 때 · GL 이면 GL 위성). */
|
||||
satellite: boolean;
|
||||
arrows: boolean;
|
||||
/** 유입 강도 색칠 — B04 지도와 같은 색띠·같은 값(2026-08-01 사용자 지시). */
|
||||
strength: boolean;
|
||||
@@ -27,11 +29,18 @@ export function mountDrainageShow(
|
||||
redraw: () => void;
|
||||
},
|
||||
): DrainageShow {
|
||||
const show: DrainageShow = { arrows: true, strength: true, hotspots: false, upstream: true };
|
||||
const show: DrainageShow = {
|
||||
satellite: false,
|
||||
arrows: true,
|
||||
strength: true,
|
||||
hotspots: false,
|
||||
upstream: true,
|
||||
};
|
||||
deps.backgroundImage.hidden = true;
|
||||
mountDrainageToggles(layerButtons, {
|
||||
initial: { satellite: false, ...show },
|
||||
initial: show,
|
||||
onSatellite: (next) => {
|
||||
show.satellite = next;
|
||||
deps.backgroundImage.hidden = !next;
|
||||
deps.redraw();
|
||||
},
|
||||
|
||||
@@ -115,7 +115,7 @@ export async function mountRouteEditor(
|
||||
options: RouteEditOptions,
|
||||
): Promise<void> {
|
||||
const stationIntervalM = options.stationIntervalM ?? 20;
|
||||
const chrome = createRouteEditChrome(parent, options.tools);
|
||||
const chrome = createRouteEditChrome(parent, options.tools, host);
|
||||
const { overlay, canvas, status, measureBox, measureText, measureButton } = chrome;
|
||||
const { minorContourButton, terrainName, stationSnapButton } = chrome;
|
||||
const context = canvas.getContext("2d")!;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools";
|
||||
import { createSidePanels } from "@ui/ui_template_side_panels";
|
||||
import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint";
|
||||
import { bindMarkToggle } from "./B05_Profile_UI_RouteEdit_Marks";
|
||||
import { createRouteEditGl } from "./B05_Profile_UI_RouteEdit_Gl";
|
||||
import { bottomRowNarrow, infoLiftPx } from "./logic/B05_Profile_RouteEdit_InfoLift";
|
||||
|
||||
/* =============================================================================
|
||||
@@ -85,6 +86,7 @@ let infoCollapsed = false;
|
||||
export function createRouteEditChrome(
|
||||
parent: HTMLElement,
|
||||
tools: HTMLElement[] = [],
|
||||
host?: { onDispose: (fn: () => void) => void },
|
||||
): RouteEditChrome {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "b05-routeedit";
|
||||
@@ -247,6 +249,7 @@ export function createRouteEditChrome(
|
||||
const measureButton = mapTools.button("measure")!;
|
||||
const busy = overlay.querySelector<HTMLElement>(".b05-routeedit__busy")!;
|
||||
const minorContourButton = mapTools.button("contour-minor")!;
|
||||
createRouteEditGl(canvas, minorContourButton, host); // GL 바탕(589-2 · 그리기가 `routeGlOf` 로 찾음)
|
||||
const stationSnapButton = mapTools.button("station-snap")!;
|
||||
bindFootprintToggle(mapTools.button("footprint")!);
|
||||
bindMarkToggle(mapTools.button("stations")!, "stations");
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface RouteEditContours {
|
||||
/** 등고선 간격(m) — 라벨을 몇 줄마다 낼지 정하는 기준. */
|
||||
intervalM: number;
|
||||
source: "las" | "sheet";
|
||||
/** 준비할 때 쓴 메타(정규화 자) — GL 바탕이 경위도로 되돌릴 때(589-2). */
|
||||
meta?: VWorldMeta;
|
||||
/** 5m 배수가 아닌 **1m 간격 선**(계획서 18 ②) — 켰을 때 처음 한 번 준비한다(무거워서).
|
||||
* LAS 자료가 없으면(도엽 갈래) null — 도엽에는 1m 선이 없다. */
|
||||
minor: (() => PreparedLayer) | null;
|
||||
@@ -101,6 +103,7 @@ export async function loadRouteEditContours(
|
||||
layer: prepareMetricPolylines(lines, meta),
|
||||
intervalM: LAS_CONTOUR_UNIT_M,
|
||||
source: "las",
|
||||
meta,
|
||||
minor: rest.length ? () => (minor ??= prepareMetricPolylines(toLines(rest), meta)) : null,
|
||||
};
|
||||
}
|
||||
@@ -109,7 +112,7 @@ export async function loadRouteEditContours(
|
||||
}
|
||||
}
|
||||
const layer = prepareLayer(sheet ?? undefined, normalizer, SHEET_ELEVATION_KEYS);
|
||||
return { layer, intervalM: inferIntervalM(layer), source: "sheet", minor: null };
|
||||
return { layer, intervalM: inferIntervalM(layer), source: "sheet", meta, minor: null };
|
||||
}
|
||||
|
||||
/** 잘라 받은 도엽(`clip_lonlat`)이 노선 띠(bbox + `bandM`)를 덮나 — 안 잘렸으면 덮음(509-1).
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Gl.ts
|
||||
* 평면 노선 편집 GL 바탕(PLAN 589-2) — 바탕칠 · 등고선(LAS 5 m · 1 m 선 · 도엽) · 도엽 하천을 MapLibre 로.
|
||||
* TM 겹(LAS) · 도엽 모두 준비한 겹(정규화 · TM 자리)을 경위도로 되돌려 올림(`preparedToGeoJson`) —
|
||||
* 캔버스와 같은 자리 · 같은 솎기(간격 배수 · 1 m 선 켬 조건) · 노선 둘레 띠 자르기(지도 칸 clip-path) ·
|
||||
* 하천 = LAS 등고선이 있는 데까지(자료를 상자로 자름 · 0-9 ⑮).
|
||||
* 고른 등고선 · 등고 글 · 노선 · 꼭짓점 · 곡선 · 측점 · 재기 · 끌기 = 위 캔버스 그대로 · 집기 판정 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { FilterSpecification } from "maplibre-gl";
|
||||
import { createIconButton } from "@ui/ui_template_elements";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { createTmGl, GL_TOGGLE_TITLE, lineSpecs } from "../B04_PreProcess/B04_PreProcess_UI_MapGl";
|
||||
import {
|
||||
createTmNormalizer,
|
||||
normalizedToScreen,
|
||||
type Normalizer,
|
||||
type PreparedLayer,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import {
|
||||
lonLatOfNormFn,
|
||||
preparedToGeoJson,
|
||||
} from "../B04_PreProcess/logic/B04_PreProcess_UI_MapGl_Frame";
|
||||
import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
|
||||
import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render";
|
||||
import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
|
||||
type Key = "sheets" | "minor" | "contour";
|
||||
|
||||
/** 간격 배수만(높이 모르는 줄은 늘) — 캔버스 `drawPreparedLayer(everyM)` 와 같은 거름. */
|
||||
const stepFilter = (step: number): FilterSpecification =>
|
||||
(step > 0
|
||||
? ["any", ["!", ["has", "level"]], ["==", ["%", ["get", "level"], step], 0]]
|
||||
: ["literal", true]) as unknown as FilterSpecification;
|
||||
|
||||
export interface RouteEditGl {
|
||||
/** 그리기마다 — 켜져 있으면 화면을 따라가고 참(캔버스는 바탕을 쉼) · `again` = 장면 다시 그리기(켬끔 뒤). */
|
||||
follow: (scene: RouteEditScene, bandM: number, again: () => void) => boolean;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
const BY_CANVAS = new WeakMap<HTMLCanvasElement, RouteEditGl>();
|
||||
/** 그 캔버스의 GL 바탕(편집 뼈대가 만듦) — 없으면 옛 캔버스 그대로. */
|
||||
export const routeGlOf = (canvas: HTMLCanvasElement): RouteEditGl | undefined =>
|
||||
BY_CANVAS.get(canvas);
|
||||
|
||||
/** `canvas` = 편집 캔버스(칸 = 그 부모) · `after` = 켬끔 단추를 그 뒤에(도구줄) · `host` = 떠날 때 버림. */
|
||||
export function createRouteEditGl(
|
||||
canvas: HTMLCanvasElement,
|
||||
after: HTMLElement,
|
||||
host?: { onDispose: (fn: () => void) => void },
|
||||
): RouteEditGl {
|
||||
const wrap = canvas.parentElement!;
|
||||
let redraw: (() => void) | null = null;
|
||||
let shown: [RouteEditContours | null, ReadonlyArray<PreparedLayer> | null] = [null, null];
|
||||
let step = 0;
|
||||
let meta: VWorldMeta | null = null;
|
||||
let normalizer: Normalizer | null = null;
|
||||
let toLonLat: ((nx: number, ny: number) => [number, number]) | null = null;
|
||||
let minorShown: PreparedLayer | null = null;
|
||||
const button = createIconButton({
|
||||
icon: "gauge",
|
||||
title: GL_TOGGLE_TITLE,
|
||||
size: 32,
|
||||
onClick: () => tm.toggle(),
|
||||
});
|
||||
button.dataset.act = "gl";
|
||||
after.after(button);
|
||||
const tm = createTmGl<Key>({
|
||||
parent: wrap,
|
||||
specs: () => [
|
||||
...lineSpecs<Key>("sheets", themeColor("--map-sheet-stream", "#2563eb"), 1.2, {
|
||||
marker: "dot",
|
||||
}),
|
||||
...lineSpecs<Key>(
|
||||
"minor",
|
||||
themeColor("--map-contour-minor", "rgba(165, 180, 252, 0.45)"),
|
||||
0.5,
|
||||
),
|
||||
...lineSpecs<Key>("contour", themeColor("--map-contour-major", "#818cf8"), 0.8, {
|
||||
filter: stepFilter(step),
|
||||
}),
|
||||
],
|
||||
themeNames: ["--draw-surface", "--map-contour-major", "--map-sheet-stream"],
|
||||
onChange: (on) => {
|
||||
mark(on);
|
||||
redraw?.();
|
||||
},
|
||||
});
|
||||
const mark = (on: boolean): void => {
|
||||
button.setAttribute("aria-pressed", String(on));
|
||||
// 바탕칠 = 칸(지도 칸은 띠로 잘림 · 캔버스는 투명)
|
||||
wrap.style.background = on ? "var(--draw-surface)" : "";
|
||||
};
|
||||
mark(tm.on());
|
||||
|
||||
/** 바탕 한 벌을 바꿈(받은 뒤 · 띠 따라 다시 받은 뒤). */
|
||||
function setBackground(
|
||||
nextMeta: VWorldMeta,
|
||||
contours: RouteEditContours | null,
|
||||
otherSheets: ReadonlyArray<PreparedLayer>,
|
||||
): void {
|
||||
meta = nextMeta;
|
||||
normalizer = createTmNormalizer(nextMeta);
|
||||
toLonLat = lonLatOfNormFn(normalizer);
|
||||
minorShown = null;
|
||||
tm.gl.setData("minor", null);
|
||||
tm.gl.setData("contour", contours ? preparedToGeoJson(contours.layer, toLonLat) : null);
|
||||
// 하천 = LAS 등고선이 있는 데까지(정규화 상자)
|
||||
const features = contours?.source === "las" ? contours.layer.features : [];
|
||||
const box = features.length
|
||||
? features.reduce(
|
||||
(got, feature) =>
|
||||
[
|
||||
Math.min(got[0], feature.minX),
|
||||
Math.min(got[1], feature.minY),
|
||||
Math.max(got[2], feature.maxX),
|
||||
Math.max(got[3], feature.maxY),
|
||||
] as const,
|
||||
[Infinity, Infinity, -Infinity, -Infinity] as const,
|
||||
)
|
||||
: undefined;
|
||||
tm.gl.setData("sheets", {
|
||||
features: otherSheets.flatMap((layer) => preparedToGeoJson(layer, toLonLat!, box).features),
|
||||
});
|
||||
}
|
||||
|
||||
const api: RouteEditGl = {
|
||||
follow(scene, bandM, again) {
|
||||
redraw = again;
|
||||
const container = tm.gl.container();
|
||||
const at = scene.contours?.meta;
|
||||
if (!tm.on() || !at || !container) return false;
|
||||
if (shown[0] !== scene.contours || shown[1] !== scene.otherSheets) {
|
||||
shown = [scene.contours, scene.otherSheets];
|
||||
setBackground(at, scene.contours, scene.otherSheets);
|
||||
}
|
||||
if (!meta || !normalizer) return false;
|
||||
const { view } = scene;
|
||||
const { gl } = tm;
|
||||
const minor =
|
||||
!!scene.minorContours && !!scene.contours && scene.contourStepM <= scene.contours.intervalM;
|
||||
if (minor && scene.minorContours !== minorShown && toLonLat) {
|
||||
minorShown = scene.minorContours;
|
||||
gl.setData("minor", preparedToGeoJson(scene.minorContours!, toLonLat));
|
||||
}
|
||||
gl.setVisible("sheets", true);
|
||||
gl.setVisible("contour", true);
|
||||
gl.setVisible("minor", minor);
|
||||
if (scene.contourStepM !== step) {
|
||||
step = scene.contourStepM;
|
||||
if (gl.map()?.getLayer("contour-line"))
|
||||
gl.map()!.setFilter("contour-line", [
|
||||
"all",
|
||||
["!=", ["geometry-type"], "Point"],
|
||||
stepFilter(step),
|
||||
] as unknown as FilterSpecification);
|
||||
}
|
||||
// 캔버스 돌림(한가운데 축) — 같은 식으로 카메라 · 띠 귀퉁이
|
||||
const cos = Math.cos(scene.rotationRad);
|
||||
const sin = Math.sin(scene.rotationRad);
|
||||
const cx = view.width / 2;
|
||||
const cy = view.height / 2;
|
||||
const turn = (x: number, y: number): [number, number] => [
|
||||
cx + (x - cx) * cos - (y - cy) * sin,
|
||||
cy + (x - cx) * sin + (y - cy) * cos,
|
||||
];
|
||||
tm.follow(
|
||||
meta,
|
||||
normalizer,
|
||||
(nx, ny) => turn(...normalizedToScreen(view, nx, ny)),
|
||||
view.width,
|
||||
view.height,
|
||||
);
|
||||
// 노선 둘레 띠만(캔버스 자르기와 같은 네모 · 돌림 · 칸 안 px)
|
||||
const line = (scene.plannedLine.length ? scene.plannedLine : scene.planned) as Vertex[];
|
||||
const band = scene.hasMeta ? contourBandRect(line, scene.toScreen, bandM) : null;
|
||||
const corners: [number, number][] = band
|
||||
? [
|
||||
[band.x, band.y],
|
||||
[band.x + band.width, band.y],
|
||||
[band.x + band.width, band.y + band.height],
|
||||
[band.x, band.y + band.height],
|
||||
]
|
||||
: [];
|
||||
container.style.clipPath = band
|
||||
? `polygon(${corners
|
||||
.map(([x, y]) =>
|
||||
tm
|
||||
.local(...turn(x, y))
|
||||
.map((v) => `${v}px`)
|
||||
.join(" "),
|
||||
)
|
||||
.join(", ")})`
|
||||
: "";
|
||||
return true;
|
||||
},
|
||||
dispose: tm.dispose,
|
||||
};
|
||||
BY_CANVAS.set(canvas, api);
|
||||
host?.onDispose(api.dispose);
|
||||
return api;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { arcLocked, straightLocked } from "./B05_Profile_UI_RouteEdit_Lock";
|
||||
import { drawFootprint } from "./B05_Profile_UI_RouteEdit_Footprint";
|
||||
import { markShown } from "./B05_Profile_UI_RouteEdit_Marks";
|
||||
import { drawStructureMarks } from "./B05_Profile_UI_RouteEdit_StructureMarks";
|
||||
import { routeGlOf } from "./B05_Profile_UI_RouteEdit_Gl";
|
||||
|
||||
/** 노드 반지름(px) — 요구 241-14 로 4 → 6(고르면 +2). */
|
||||
const NODE_R = 6;
|
||||
@@ -169,8 +170,13 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const line = scene.plannedLine.length ? scene.plannedLine : scene.planned;
|
||||
context.clearRect(0, 0, view.width, view.height);
|
||||
// GL 바탕(589-2) — 켜져 있으면 바탕칠 · 등고선 · 하천을 그쪽이 그림(고른 등고선 · 글은 여기)
|
||||
const glBase =
|
||||
routeGlOf(context.canvas)?.follow(scene, CONTOUR_BAND_M, () =>
|
||||
drawRouteEditScene(context, scene),
|
||||
) ?? false;
|
||||
context.fillStyle = style.getPropertyValue("--draw-surface") || "#111";
|
||||
context.fillRect(0, 0, view.width, view.height);
|
||||
if (!glBase) context.fillRect(0, 0, view.width, view.height);
|
||||
|
||||
// 여기서부터 **그림 전체**가 돈다 — 글자도 함께 돈다(CAD 도면과 같은 방식, 사용자 지시 ⑯).
|
||||
// 바탕칠은 돌리기 **전에** 해 두었다 — 돌린 뒤에 칠하면 모서리에 빈 곳이 생긴다.
|
||||
@@ -204,15 +210,16 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
context.rect(lasBox.x, lasBox.y, lasBox.width, lasBox.height);
|
||||
context.clip();
|
||||
}
|
||||
for (const layer of scene.otherSheets)
|
||||
drawPreparedLayer(context, layer, view, "dot", undefined, pad);
|
||||
if (!glBase)
|
||||
for (const layer of scene.otherSheets)
|
||||
drawPreparedLayer(context, layer, view, "dot", undefined, pad);
|
||||
context.restore();
|
||||
if (scene.contours) {
|
||||
// 그리는 줄과 라벨을 **같은 눈금**으로 솎는다 — 그린 줄에만 숫자가 붙어야 짝이 맞는다.
|
||||
const everyM = scene.contourStepM;
|
||||
// 1m 선은 5m 선이 솎이지 않는 배율에서만 — 5m 도 솎이는 데서 1m 를 다 그리면 선으로
|
||||
// 덮이고 무겁다. 화면 밖 가닥은 `drawPreparedLayer` 가 건너뛴다(보이는 범위만).
|
||||
if (scene.minorContours && everyM <= scene.contours.intervalM) {
|
||||
if (!glBase && scene.minorContours && everyM <= scene.contours.intervalM) {
|
||||
context.strokeStyle =
|
||||
style.getPropertyValue("--map-contour-minor") || "rgba(165, 180, 252, 0.45)";
|
||||
context.lineWidth = 0.5;
|
||||
@@ -221,7 +228,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
// 5m 선은 도엽색보다 한 단 짙게(계획서 18 ②).
|
||||
context.strokeStyle = style.getPropertyValue("--map-contour-major") || "#818cf8";
|
||||
context.lineWidth = 0.8;
|
||||
drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM, pad);
|
||||
if (!glBase) drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM, pad);
|
||||
// 고른 가닥은 굵고 다른 색으로 덧그린다 — 지우고 다시 그리지 않고 위에 얹는다.
|
||||
// 끊어져 여러 가닥으로 나뉜 **같은 높이 줄은 함께** 덧그린다(높이를 모르면 고른 가닥만).
|
||||
if (scene.pickedContour >= 0) {
|
||||
|
||||
@@ -38,6 +38,7 @@ html.is-bm-route .b05-routeedit__tools {
|
||||
}
|
||||
|
||||
.b05-routeedit__canvas {
|
||||
position: relative; /* GL 바탕(589-2 · 칸 맨 밑) 위 */
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -10,32 +10,26 @@
|
||||
* 카메라 주인 = D03 화면(`ViewState` · 돌림) 하나 — 여기는 따라가기만 · 조작은 안 받음.
|
||||
* 자료 = 지금 받는 GeoJSON 그대로(조각내기는 MapLibre 일꾼 스레드) · 색 = 받는 쪽이 지물에 붙인 `_color`.
|
||||
* 그래픽 카드 · WebGL 이 없거나 잃으면 `onFail` — 부르는 쪽이 옛 캔버스로.
|
||||
* 지도 세우기 · 자료 올리기 · 카메라 = 공용 부품 `ui_template_gl_map`(589-2) · 여기는 겹 꼴만.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { ExpressionSpecification, FilterSpecification, Map as MapLibreMap } from "maplibre-gl";
|
||||
import {
|
||||
Map as MapLibreMap,
|
||||
type ExpressionSpecification,
|
||||
type FilterSpecification,
|
||||
type LayerSpecification,
|
||||
type SourceSpecification,
|
||||
} from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
createGlMap,
|
||||
GL_FONT,
|
||||
satelliteTileSource,
|
||||
type GlImage,
|
||||
type GlSpec as GlMapSpec,
|
||||
} from "@ui/ui_template_gl_map";
|
||||
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";
|
||||
|
||||
export { GL_FONT, type GlImage };
|
||||
|
||||
/** 자료 소스(임도 짐작 = 도로 소스를 거름 · 위성 = 조각). */
|
||||
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 {
|
||||
@@ -52,7 +46,7 @@ export interface GlBase {
|
||||
resize: () => void;
|
||||
}
|
||||
|
||||
type GlSpec = LayerSpecification & { key: PlanLayerKey; source: GlSource | "satellite" };
|
||||
type GlSpec = GlMapSpec<PlanLayerKey>;
|
||||
|
||||
const HALO = "rgba(0, 0, 0, 0.55)";
|
||||
const ROUND = { "line-join": "round", "line-cap": "round" } as const;
|
||||
@@ -312,160 +306,29 @@ const LINE_COLOR = {
|
||||
} as const;
|
||||
|
||||
/** 겹 색 지문 — 밝음 · 어두움이 바뀌면 색을 다시. */
|
||||
const themeKey = (): string =>
|
||||
["--draw-map-contour", "--draw-map-road", "--draw-map-stream", "--draw-map-parcel"]
|
||||
.map((name) => themeColor(name, ""))
|
||||
.join();
|
||||
|
||||
const SOURCES: readonly (GlSource | "satellite")[] = [
|
||||
"satellite",
|
||||
"hillshade",
|
||||
"slope",
|
||||
"landslide",
|
||||
"soils",
|
||||
"zones",
|
||||
"bands",
|
||||
"cadastral",
|
||||
"water",
|
||||
"building",
|
||||
"contour",
|
||||
"road",
|
||||
"forestRoadFile",
|
||||
"stream",
|
||||
"faults",
|
||||
"lineaments",
|
||||
"zeroOrder",
|
||||
"landslideZones",
|
||||
"place",
|
||||
];
|
||||
const THEME_NAMES = [
|
||||
"--draw-map-contour",
|
||||
"--draw-map-road",
|
||||
"--draw-map-stream",
|
||||
"--draw-map-parcel",
|
||||
] as const;
|
||||
|
||||
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<string, unknown>();
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
const source = (key: GlSource | "satellite"): SourceSpecification | null => {
|
||||
if (key === "satellite")
|
||||
return {
|
||||
type: "raster",
|
||||
tiles: [`${location.origin}${API_BASE_URL}/projects/${projectId}/plan/tile/{z}/{x}/{y}`],
|
||||
tileSize: 256,
|
||||
minzoom: 6,
|
||||
maxzoom: 19,
|
||||
};
|
||||
const got = data.get(key);
|
||||
if (!got) return null;
|
||||
if ("url" in got) return { type: "image", url: got.url, coordinates: got.corners };
|
||||
// 묶음 꼴 표시가 없는 자료(여러 겹을 합친 것 등)도 받게 다시 싸 줌
|
||||
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?: string): void => {
|
||||
for (const spec of specs)
|
||||
if ((!key || spec.source === key) && map!.getLayer(spec.id)) map!.removeLayer(spec.id);
|
||||
};
|
||||
if (now !== theme) {
|
||||
theme = now;
|
||||
drop(); // 밝음 · 어두움 = 색 다시
|
||||
}
|
||||
for (const key of SOURCES) {
|
||||
const got = key === "satellite" ? key : (data.get(key) ?? null);
|
||||
if (applied.get(key) === got) continue;
|
||||
applied.set(key, got);
|
||||
if (map.getSource(key)) {
|
||||
drop(key);
|
||||
map.removeSource(key);
|
||||
}
|
||||
const want = source(key);
|
||||
if (want) map.addSource(key, want);
|
||||
}
|
||||
specs.forEach(({ key, ...spec }, index) => {
|
||||
if (!map!.getSource(spec.source)) return;
|
||||
if (!map!.getLayer(spec.id)) {
|
||||
// 늦게 온 겹도 제 차례(위 겹 밑)에
|
||||
const above = specs.slice(index + 1).find((next) => map!.getLayer(next.id));
|
||||
map!.addLayer(spec as LayerSpecification, above?.id);
|
||||
}
|
||||
map!.setLayoutProperty(spec.id, "visibility", visible.get(key) ? "visible" : "none");
|
||||
});
|
||||
}
|
||||
|
||||
function teardown(): void {
|
||||
map?.remove();
|
||||
container?.remove();
|
||||
map = container = null;
|
||||
ready = false;
|
||||
theme = "";
|
||||
applied.clear();
|
||||
}
|
||||
|
||||
const base = createGlMap<PlanLayerKey>({
|
||||
specs: () => layerSpecs(step),
|
||||
fixed: { satellite: satelliteTileSource(projectId) },
|
||||
themeNames: THEME_NAMES,
|
||||
className: "d03-plan__gl",
|
||||
onFail,
|
||||
});
|
||||
return {
|
||||
setData(key, next) {
|
||||
if (data.get(key) === next) return;
|
||||
data.set(key, next);
|
||||
apply();
|
||||
},
|
||||
setVisible(key, on) {
|
||||
if (visible.get(key) === on) return;
|
||||
visible.set(key, on);
|
||||
apply();
|
||||
},
|
||||
...base,
|
||||
setContourStep(next) {
|
||||
if (next === step) return;
|
||||
step = next;
|
||||
const map = base.map();
|
||||
if (map?.getLayer("contour-minor")) map.setFilter("contour-minor", minorFilter(step));
|
||||
},
|
||||
enable(parent) {
|
||||
if (!parent) {
|
||||
teardown();
|
||||
return false;
|
||||
}
|
||||
if (map) return true;
|
||||
container = document.createElement("div");
|
||||
container.className = "d03-plan__gl";
|
||||
parent.prepend(container);
|
||||
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) {
|
||||
if (!map) return;
|
||||
if (ready && themeKey() !== theme) apply();
|
||||
map.jumpTo({ center, zoom, bearing: bearingDeg });
|
||||
if (ready) map.redraw(); // 캔버스와 같은 화면(다음 화면으로 미루지 않음)
|
||||
},
|
||||
resize: () => map?.resize(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/* 589-2 GL 바탕 따라가기 헬퍼 — `logic/B04_PreProcess_UI_MapGl_Frame.ts` 의 `glCameraOf` ·
|
||||
* `lonLatOfNormFn` · `preparedToGeoJson` · `ringsToPolygons` 를 stdin 입력으로 돌려 JSON 으로 냄.
|
||||
* 입력 { meta, lonlat: [[lon, lat]], views: [[w, h, scale, ox, oy, rotDeg]], norm: [[nx, ny]],
|
||||
* layer, box, rings }
|
||||
* views 마다 = 카메라 + 점마다 [캔버스 화면 px, MapLibre 식(세계 512 px · 돌림) 화면 px]
|
||||
*/
|
||||
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 frame = load("B04_PreProcess", "logic", "B04_PreProcess_UI_MapGl_Frame.ts");
|
||||
|
||||
const input = JSON.parse(require("fs").readFileSync(0, "utf-8"));
|
||||
const { meta } = input;
|
||||
const norm = render.createTmNormalizer(meta);
|
||||
|
||||
/** MapLibre 투영(웹 메르카토르 · 세계 512·2^z px · 돌림 = 화면 = R(−도) · 세계). */
|
||||
function glScreen(camera, w, h, lon, lat) {
|
||||
const world = 512 * 2 ** camera.zoom;
|
||||
const X = (l) => ((l + 180) / 360) * world;
|
||||
const Y = (p) =>
|
||||
(0.5 - Math.log(Math.tan(Math.PI / 4 + (p * Math.PI) / 360)) / (2 * Math.PI)) * world;
|
||||
const dx = X(lon) - X(camera.center[0]);
|
||||
const dy = Y(lat) - Y(camera.center[1]);
|
||||
const a = (-camera.bearing * Math.PI) / 180;
|
||||
const gx = dx * Math.cos(a) - dy * Math.sin(a);
|
||||
const gy = dx * Math.sin(a) + dy * Math.cos(a);
|
||||
// 지도 칸 CSS 행렬(칸 가운데 축)
|
||||
const [ca, cb, cc, cd] = camera.css;
|
||||
return [w / 2 + ca * gx + cc * gy, h / 2 + cb * gx + cd * gy];
|
||||
}
|
||||
|
||||
const out = {
|
||||
views: input.views.map(([w, h, scale, ox, oy, rotDeg]) => {
|
||||
const view = { width: w, height: h, scale, offsetX: ox, offsetY: oy };
|
||||
view.mapRect = render.computeMapRect(meta, w, h);
|
||||
const r = (rotDeg * Math.PI) / 180;
|
||||
const turn = ([x, y]) => [
|
||||
w / 2 + (x - w / 2) * Math.cos(r) - (y - h / 2) * Math.sin(r),
|
||||
h / 2 + (x - w / 2) * Math.sin(r) + (y - h / 2) * Math.cos(r),
|
||||
];
|
||||
const toScreen = (nx, ny) => turn(render.normalizedToScreen(view, nx, ny));
|
||||
const camera = frame.glCameraOf(meta, norm, toScreen, w, h);
|
||||
const points = input.lonlat.map(([lon, lat]) => {
|
||||
const [nx, ny] = prep.normOfLonLat(norm, lon, lat);
|
||||
return [toScreen(nx, ny), glScreen(camera, w, h, lon, lat)];
|
||||
});
|
||||
// 화면 격자 → (되돌림) 정규화 → 경위도 → GL 화면 = 제자리여야
|
||||
const [ox0, oy0] = toScreen(0, 0);
|
||||
const [ox1, oy1] = toScreen(1, 0);
|
||||
const [ox2, oy2] = toScreen(0, 1);
|
||||
const ex = [ox1 - ox0, oy1 - oy0];
|
||||
const ey = [ox2 - ox0, oy2 - oy0];
|
||||
const det = ex[0] * ey[1] - ex[1] * ey[0];
|
||||
const toLonLat = frame.lonLatOfNormFn(norm);
|
||||
const screen = [];
|
||||
for (let i = 0; i <= 4; i += 1)
|
||||
for (let j = 0; j <= 4; j += 1) {
|
||||
const sx = (w * i) / 4;
|
||||
const sy = (h * j) / 4;
|
||||
const dx = sx - ox0;
|
||||
const dy = sy - oy0;
|
||||
const nx = (dx * ey[1] - dy * ey[0]) / det;
|
||||
const ny = (ex[0] * dy - ex[1] * dx) / det;
|
||||
const [lon, lat] = toLonLat(nx, ny);
|
||||
screen.push([[sx, sy], glScreen(camera, w, h, lon, lat)]);
|
||||
}
|
||||
return { camera, points, screen };
|
||||
}),
|
||||
back: (() => {
|
||||
const toLonLat = frame.lonLatOfNormFn(norm);
|
||||
return input.norm.map(([nx, ny]) => {
|
||||
const [lon, lat] = toLonLat(nx, ny);
|
||||
return [lon, lat, ...prep.normOfLonLat(norm, lon, lat)];
|
||||
});
|
||||
})(),
|
||||
geo: frame.preparedToGeoJson(input.layer, (nx, ny) => [nx, ny], input.box),
|
||||
geoAll: frame.preparedToGeoJson(input.layer, (nx, ny) => [nx, ny]),
|
||||
polygons: frame.ringsToPolygons(input.rings),
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,170 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""589-2 B04 · B05 GL 바탕 — MapLibre 카메라가 TM 주인 화면(589-1 자리)을 따라감.
|
||||
|
||||
- 카메라(화면 가운데 1차 맞춤 · 닮음꼴) + 칸 CSS 행렬(남는 늘림) → MapLibre 식 화면 px = 캔버스 자리
|
||||
(격자 쌍선형 · 화면 식) 0.5 px 안(처음 화면 · 노선 자리 · 크게 당김 · 돌림 30° · 화면 밖 반 폭까지)
|
||||
- 칸 행렬 = 늘이기만(고윳값 ≥ 1 · 웹 메르카토르 가로세로 차 0.4 %)
|
||||
- TM 겹 되돌림(정규화 → 경위도) — 다시 정규화하면 1 mm 안 · pyproj 와 2 cm 안
|
||||
- 준비한 겹 → GeoJSON — 상자 자르기(하천 = LAS 등고선 있는 데까지) · 닫힌 고리 · 점
|
||||
- 유역 고리(조각 · 구멍) → 다각형 — 캔버스 짝홀 채움과 같은 묶음
|
||||
|
||||
Node 헬퍼(`helper_589_2_gl_frame.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
|
||||
from resources.tester.test_589_tm_grid import SITES, _meta
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
# (w, h, 배율, 옮김 x, y, 돌림 도) — 처음 화면 · 노선 자리 · 크게 당김 · 돌림
|
||||
VIEWS = [
|
||||
(1400, 560, 1, 0, 0, 0),
|
||||
(1200, 700, 2.33, -120, 80, 0),
|
||||
(1400, 560, 300, -60000, 40000, 0),
|
||||
(1200, 700, 4, 200, -150, 30),
|
||||
]
|
||||
LAYER = {
|
||||
"features": [
|
||||
{
|
||||
"kind": "line",
|
||||
"labelValue": 125,
|
||||
"parts": [{"coords": [0.0, 0.5, 0.5, 0.5, 1.0, 0.5], "closed": False}],
|
||||
},
|
||||
{
|
||||
"kind": "line",
|
||||
"labelValue": None,
|
||||
"parts": [{"coords": [0.2, 0.2, 0.4, 0.2, 0.4, 0.4], "closed": True}],
|
||||
},
|
||||
{"kind": "point", "labelValue": None, "parts": [{"coords": [0.3, 0.3, 0.9, 0.9]}]},
|
||||
]
|
||||
}
|
||||
BOX = [0.25, 0.0, 0.75, 1.0]
|
||||
RINGS = [
|
||||
[[0, 0], [10, 0], [10, 10], [0, 10]], # 바깥
|
||||
[[2, 2], [8, 2], [8, 8], [2, 8]], # 구멍
|
||||
[[4, 4], [6, 4], [6, 6], [4, 6]], # 구멍 속 섬
|
||||
[[20, 0], [25, 0], [25, 5]], # 떨어진 조각
|
||||
]
|
||||
|
||||
|
||||
def _run(site):
|
||||
crs, box, _ = SITES[site]
|
||||
meta = _meta(crs, box)
|
||||
meta["tm_grid"] = tm_grid_for_meta(meta, crs)
|
||||
lon_min, lon_max, lat_min, lat_max = box
|
||||
# 상자 안 · 둘레 반 폭(화면 밖까지)
|
||||
lonlat = [
|
||||
(lon_min + a / 8 * (lon_max - lon_min), lat_min + b / 8 * (lat_max - lat_min))
|
||||
for a in range(-4, 13)
|
||||
for b in range(-4, 13)
|
||||
]
|
||||
norm = [(a / 6, b / 6) for a in range(-1, 8) for b in range(-1, 8)]
|
||||
payload = {
|
||||
"meta": meta,
|
||||
"lonlat": lonlat,
|
||||
"views": VIEWS,
|
||||
"norm": norm,
|
||||
"layer": LAYER,
|
||||
"box": BOX,
|
||||
"rings": RINGS,
|
||||
}
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_589_2_gl_frame.cjs")],
|
||||
input=json.dumps(payload),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return meta, norm, json.loads(proc.stdout)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", params=["B", "D"])
|
||||
def site(request):
|
||||
return request.param, *_run(request.param)
|
||||
|
||||
|
||||
def test_GL_카메라는_캔버스_자리를_1px_안에(site):
|
||||
name, _, _, out = site
|
||||
for (w, h, scale, *_rest), view in zip(VIEWS, out["views"]):
|
||||
worst = 0.0
|
||||
for canvas, gl in view["points"]:
|
||||
# 화면 안 · 둘레 반 폭까지만 잰다(그 밖은 안 보임)
|
||||
if not (-w / 2 <= canvas[0] <= w * 1.5 and -h / 2 <= canvas[1] <= h * 1.5):
|
||||
continue
|
||||
worst = max(worst, math.hypot(canvas[0] - gl[0], canvas[1] - gl[1]))
|
||||
assert worst < 0.5, (name, scale, worst)
|
||||
# 화면 격자(귀퉁이 · 가장자리 · 가운데) — 되돌림 거쳐 제자리
|
||||
corner = max(math.hypot(s[0] - g[0], s[1] - g[1]) for s, g in view["screen"])
|
||||
assert corner < 0.5, (name, scale, corner)
|
||||
|
||||
|
||||
def test_남는_늘림은_칸_행렬로_늘이기만(site):
|
||||
_, _, _, out = site
|
||||
for view in out["views"]:
|
||||
a, b, c, d = view["camera"]["css"]
|
||||
# 대칭 · 고윳값 ≥ 1 · 웹 메르카토르 가로세로 차(0.4 %) 안팎
|
||||
assert abs(b - c) < 1e-9
|
||||
trace, det = a + d, a * d - b * c
|
||||
least = trace / 2 - math.sqrt(max(trace * trace / 4 - det, 0))
|
||||
most = trace / 2 + math.sqrt(max(trace * trace / 4 - det, 0))
|
||||
assert least == pytest.approx(1, abs=1e-9) and 1.003 < most < 1.006
|
||||
|
||||
|
||||
def test_돌림은_카메라_도에_그대로():
|
||||
_, _, out = _run("D")
|
||||
plain = out["views"][1]["camera"]["bearing"]
|
||||
turned = out["views"][3]["camera"]["bearing"]
|
||||
# 화면 30° 돌림 = 카메라 −30° (+ 수렴각 몫은 같음)
|
||||
assert abs((turned - plain) + 30) < 0.01
|
||||
|
||||
|
||||
def test_TM_되돌림은_다시_정규화하면_제자리(site):
|
||||
name, meta, norm, out = site
|
||||
inv = Transformer.from_crs(SITES[name][0], "EPSG:4326", always_xy=True)
|
||||
for (nx, ny), (lon, lat, bx, by) in zip(norm, out["back"]):
|
||||
meters = math.hypot((bx - nx) * meta["width_meters"], (by - ny) * meta["height_meters"])
|
||||
assert meters < 1e-3, (nx, ny, meters)
|
||||
x = meta["x_min"] + nx * meta["width_meters"]
|
||||
y = meta["y_min"] + (1 - ny) * meta["height_meters"]
|
||||
want = inv.transform(x, y)
|
||||
fwd = Transformer.from_crs("EPSG:4326", SITES[name][0], always_xy=True)
|
||||
gx, gy = fwd.transform(lon, lat)
|
||||
assert math.hypot(gx - x, gy - y) < 0.02, (nx, ny, want)
|
||||
|
||||
|
||||
def test_준비한_겹_GeoJSON_자르기():
|
||||
_, _, out = _run("B")
|
||||
lines = [f for f in out["geo"]["features"] if f["geometry"]["type"] == "LineString"]
|
||||
# 가로줄 → 상자 0.25~0.75 만 · 높이 그대로
|
||||
first = lines[0]
|
||||
assert first["properties"] == {"level": 125}
|
||||
xs = [p[0] for p in first["geometry"]["coordinates"]]
|
||||
assert min(xs) == pytest.approx(0.25) and max(xs) == pytest.approx(0.75)
|
||||
# 닫힌 고리 → 상자 안 조각만(0.25 왼쪽 몫 잘림)
|
||||
ring = lines[1]["geometry"]["coordinates"]
|
||||
assert all(p[0] >= 0.25 - 1e-12 for p in ring)
|
||||
# 점 — 상자 밖(0.9) 뺌
|
||||
points = [f for f in out["geo"]["features"] if f["geometry"]["type"] == "MultiPoint"]
|
||||
assert points[0]["geometry"]["coordinates"] == [[0.3, 0.3]]
|
||||
# 상자 없으면 통째 · 닫힌 고리는 첫 점으로 닫음
|
||||
closed = out["geoAll"]["features"][1]["geometry"]["coordinates"]
|
||||
assert closed[0] == closed[-1] and len(closed) == 4
|
||||
|
||||
|
||||
def test_유역_고리는_짝홀_다각형():
|
||||
_, _, out = _run("B")
|
||||
polygons = out["polygons"]
|
||||
# 바깥(+ 구멍) · 구멍 속 섬 · 떨어진 조각 = 셋
|
||||
assert len(polygons) == 3
|
||||
assert len(polygons[0]) == 2 and polygons[0][1][0] == [2, 2]
|
||||
assert len(polygons[1]) == 1 and polygons[1][0][0] == [4, 4]
|
||||
assert len(polygons[2]) == 1 and polygons[2][0][0] == [20, 0]
|
||||
@@ -0,0 +1,6 @@
|
||||
/* 지도 GL 바탕 부품 — 위 캔버스 밑 MapLibre 한 장 · 누름은 위 캔버스가 받음 */
|
||||
.ui-gl-map {
|
||||
position: absolute;
|
||||
inset: -8px; /* 둘레 여유 — 부르는 쪽 칸 행렬(늘림)이 귀퉁이를 비우지 않게(가운데는 그대로) */
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/* =============================================================================
|
||||
* ui_template_gl_map.ts
|
||||
* 지도 GL 바탕 부품(PLAN 586 · 589-2 · 요구 448) — 바탕 겹을 MapLibre(WebGL · BSD-3) 한 장으로 그림.
|
||||
* 쓰는 곳 = D03 노선분석(`D03_PlanAnalysis_UI_MapGl`) · B04 하단 지도 · B05 평면 노선 편집 · 배수유역도
|
||||
* (`B04_PreProcess_UI_MapGl`). 겹 꼴(`specs`) · 자료는 부르는 쪽 몫 — 여기는 지도 세우기 · 자료 올리기 ·
|
||||
* 켬끔 · 밝음/어두움 색 다시 · 카메라 따라가기만.
|
||||
* 카메라 주인 = 부르는 화면 하나 — 여기는 따라가기만 · 조작은 안 받음(누름 · 끌기는 위 캔버스).
|
||||
* 글꼴 = 화면 Noto Sans KR(OFL · 자체 보관) — 꼴에 글꼴 주소가 없으면 MapLibre 가 화면 글꼴로 그림.
|
||||
* 그래픽 카드 · WebGL 이 없거나 잃으면 `onFail` — 부르는 쪽이 옛 캔버스로.
|
||||
* 쓰는 법 = createGlMap({ specs, fixed, themeNames, onFail }) → enable(parent) · setData · setVisible · camera
|
||||
* ========================================================================== */
|
||||
|
||||
import { Map as MapLibreMap, type LayerSpecification, type SourceSpecification } from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import "./ui_template_gl_map.css";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { themeColor } from "./ui_template_palette";
|
||||
|
||||
/** 그림 겹 — 주소 + 네 귀퉁이 경위도(왼쪽 위 · 오른쪽 위 · 오른쪽 아래 · 왼쪽 아래). */
|
||||
export interface GlImage {
|
||||
url: string;
|
||||
corners: [[number, number], [number, number], [number, number], [number, number]];
|
||||
}
|
||||
|
||||
/** 조각 겹(위성 조각 등 · 프로젝트마다 주소가 다른 것). */
|
||||
export interface GlRaster {
|
||||
raster: SourceSpecification;
|
||||
}
|
||||
|
||||
/** 경위도 GeoJSON 묶음(꼴 표시가 없어도 받음). */
|
||||
export interface GlCollection {
|
||||
features?: unknown[];
|
||||
}
|
||||
|
||||
/** 글꼴 — 화면 `@font-face`(ui_template/fonts · OFL) 이름. 미리 받아 둬야 첫 글자가 대체 글꼴로 안 굳음. */
|
||||
export const GL_FONT = "Noto Sans KR";
|
||||
|
||||
export type GlData = GlCollection | GlImage | GlRaster;
|
||||
|
||||
/** 겹 하나 — `key` = 켬끔 이름 · `source` = 자료 이름. */
|
||||
export type GlSpec<K extends string = string> = LayerSpecification & { key: K; source: string };
|
||||
|
||||
export interface GlMap<K extends string = string> {
|
||||
/** 자료 담기(지도 없어도 · 켤 때 올림) — null = 비움. */
|
||||
setData: (source: string, data: GlData | null) => void;
|
||||
setVisible: (key: K, on: boolean) => void;
|
||||
/** 켬 = 지도를 `parent` 맨 앞에 만듦(참 = 섬 · 거짓 = WebGL 못 씀) · null = 버림. */
|
||||
enable: (parent: HTMLElement | null) => boolean;
|
||||
map: () => MapLibreMap | null;
|
||||
/** 지도 칸(켰을 때) — 자르기 · 바탕색은 부르는 쪽. */
|
||||
container: () => HTMLDivElement | null;
|
||||
/** 화면을 따라감(경위도 가운데 · 줌 · 돌림 도) — 같은 화면에 바로 그림. */
|
||||
camera: (center: [number, number], zoom: number, bearingDeg: number) => void;
|
||||
resize: () => void;
|
||||
}
|
||||
|
||||
/** 브이월드 위성 조각(서버 대리 · 서버 기억 z6~19 · `D03_PlanAnalysis_Router`). */
|
||||
export function satelliteTileSource(projectId: string): SourceSpecification {
|
||||
return {
|
||||
type: "raster",
|
||||
tiles: [`${location.origin}${API_BASE_URL}/projects/${projectId}/plan/tile/{z}/{x}/{y}`],
|
||||
tileSize: 256,
|
||||
minzoom: 6,
|
||||
maxzoom: 19,
|
||||
};
|
||||
}
|
||||
|
||||
export function createGlMap<K extends string>(opts: {
|
||||
/** 겹 차례(아래 → 위)와 꼴 — 밝음 · 어두움이 바뀌면 다시 부름. */
|
||||
specs: () => GlSpec<K>[];
|
||||
/** 늘 올리는 자료(위성 조각 등). */
|
||||
fixed?: Record<string, SourceSpecification>;
|
||||
/** 색 지문 — 이 색 값이 바뀌면 겹을 다시 만듦. */
|
||||
themeNames: readonly string[];
|
||||
/** 지도 칸 클래스(없으면 `ui-gl-map` · 칸 가득 · 누름 안 받음). */
|
||||
className?: string;
|
||||
onFail: (why: string) => void;
|
||||
}): GlMap<K> {
|
||||
const data = new Map<string, GlData | null>();
|
||||
const visible = new Map<K, boolean>();
|
||||
let theme = "";
|
||||
let map: MapLibreMap | null = null;
|
||||
/** 첫 꼴이 섬(`isStyleLoaded` 는 조각 받는 동안 거짓이라 안 씀). */
|
||||
let ready = false;
|
||||
const applied = new Map<string, unknown>();
|
||||
let container: HTMLDivElement | null = null;
|
||||
const themeKey = (): string => opts.themeNames.map((name) => themeColor(name, "")).join();
|
||||
|
||||
const source = (key: string): SourceSpecification | null => {
|
||||
const fixed = opts.fixed?.[key];
|
||||
if (fixed) return fixed;
|
||||
const got = data.get(key);
|
||||
if (!got) return null;
|
||||
if ("raster" in got) return got.raster;
|
||||
if ("url" in got) return { type: "image", url: got.url, coordinates: got.corners };
|
||||
// 묶음 꼴 표시가 없는 자료(여러 겹을 합친 것 등)도 받게 다시 싸 줌
|
||||
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 = opts.specs();
|
||||
const drop = (key?: string): void => {
|
||||
for (const spec of specs)
|
||||
if ((!key || spec.source === key) && map!.getLayer(spec.id)) map!.removeLayer(spec.id);
|
||||
};
|
||||
if (now !== theme) {
|
||||
theme = now;
|
||||
drop(); // 밝음 · 어두움 = 색 다시
|
||||
}
|
||||
for (const key of new Set(specs.map((spec) => spec.source))) {
|
||||
const got = opts.fixed?.[key] ?? data.get(key) ?? null;
|
||||
if (applied.get(key) === got) continue;
|
||||
applied.set(key, got);
|
||||
if (map.getSource(key)) {
|
||||
drop(key);
|
||||
map.removeSource(key);
|
||||
}
|
||||
const want = source(key);
|
||||
if (want) map.addSource(key, want);
|
||||
}
|
||||
specs.forEach(({ key, ...spec }, index) => {
|
||||
if (!map!.getSource(spec.source)) return;
|
||||
if (!map!.getLayer(spec.id)) {
|
||||
// 늦게 온 겹도 제 차례(위 겹 밑)에
|
||||
const above = specs.slice(index + 1).find((next) => map!.getLayer(next.id));
|
||||
map!.addLayer(spec as LayerSpecification, above?.id);
|
||||
}
|
||||
map!.setLayoutProperty(spec.id, "visibility", visible.get(key) ? "visible" : "none");
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
data.set(key, next);
|
||||
apply();
|
||||
},
|
||||
setVisible(key, on) {
|
||||
if (visible.get(key) === on) return;
|
||||
visible.set(key, on);
|
||||
apply();
|
||||
},
|
||||
enable(parent) {
|
||||
if (!parent) {
|
||||
teardown();
|
||||
return false;
|
||||
}
|
||||
if (map) return true;
|
||||
container = document.createElement("div");
|
||||
container.className = opts.className ?? "ui-gl-map";
|
||||
parent.prepend(container);
|
||||
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("GL 바탕 못 켬", error);
|
||||
opts.onFail("브라우저가 WebGL 을 열지 못함");
|
||||
return false;
|
||||
}
|
||||
map.on("load", () => {
|
||||
ready = true;
|
||||
apply();
|
||||
});
|
||||
map.on("webglcontextlost", () => opts.onFail("그래픽 카드 문맥을 잃음"));
|
||||
return true;
|
||||
},
|
||||
map: () => map,
|
||||
container: () => container,
|
||||
camera(center, zoom, bearingDeg) {
|
||||
if (!map) return;
|
||||
if (ready && themeKey() !== theme) apply();
|
||||
map.jumpTo({ center, zoom, bearing: bearingDeg });
|
||||
if (ready) map.redraw(); // 캔버스와 같은 화면(다음 화면으로 미루지 않음)
|
||||
},
|
||||
resize: () => map?.resize(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user