/* ============================================================================= * 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 { gl: GlMap; /** 지금 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(opts: { parent: HTMLElement; specs: () => GlSpec[]; themeNames: readonly string[]; onChange: (on: boolean) => void; }): TmGl { 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({ 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 = (key: K, source: string = key): GlSpec => ({ key, id: `${key}`, type: "raster", source, paint: { "raster-opacity": 1, "raster-fade-duration": 0 }, }) as GlSpec; /** 선 겹(캔버스 `drawPreparedLayer` 꼴) — 선 + 점(점 = 지름 4 동그라미 · x = 표고점 X 글자). */ export function lineSpecs( key: K, color: string | ExpressionSpecification, width: number, opts: { source?: string; marker?: "dot" | "x"; filter?: unknown; opacity?: number; dash?: number[]; } = {}, ): GlSpec[] { const source = opts.source ?? key; const lineFilter = ( opts.filter ? ["all", NOT_POINT, opts.filter] : NOT_POINT ) as FilterSpecification; const specs: GlSpec[] = [ { 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, ]; 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); 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); 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], ];