Files
Aislo/B04_PreProcess/B04_PreProcess_UI_MapGl.ts
T
eomsangdonandClaude Opus 5.5 21afc379e4 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>
2026-10-29 21:45:41 +09:00

216 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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],
];