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:
2026-10-29 21:45:41 +09:00
co-authored by Claude Opus 5.5
parent 49255c07cb
commit 21afc379e4
20 changed files with 1534 additions and 201 deletions
+215
View File
@@ -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]);
+13 -14
View File
@@ -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;
}