/* ============================================================================= * 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 = LayerSpecification & { key: K; source: string }; export interface GlMap { /** 자료 담기(지도 없어도 · 켤 때 올림) — 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(opts: { /** 겹 차례(아래 → 위)와 꼴 — 밝음 · 어두움이 바뀌면 다시 부름. */ specs: () => GlSpec[]; /** 늘 올리는 자료(위성 조각 등). */ fixed?: Record; /** 색 지문 — 이 색 값이 바뀌면 겹을 다시 만듦. */ themeNames: readonly string[]; /** 지도 칸 클래스(없으면 `ui-gl-map` · 칸 가득 · 누름 안 받음). */ className?: string; onFail: (why: string) => void; }): GlMap { const data = new Map(); const visible = new Map(); let theme = ""; let map: MapLibreMap | null = null; /** 첫 꼴이 섬(`isStyleLoaded` 는 조각 받는 동안 거짓이라 안 씀). */ let ready = false; const applied = new Map(); 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(), }; }