/* ============================================================================= * D03_PlanAnalysis_UI_MapGl.ts * D03 GL 바탕 시험(PLAN 586-1 · 요구 445 B) — 바탕 겹 여섯을 MapLibre(WebGL · BSD-3)로: * 위성 조각(서버 대리 z6~19) · 급경사 그림 · 지적 · 등고선(주곡선 · 계곡선 25 m) · 도로 · 세류 · 하천. * 그 밖 겹 · 등고 글 · 그은 선 · 재기 · 누름 = 지금 캔버스 그대로(위에 겹침). * 카메라 = D03 화면(`ViewState` · 돌림)을 따라감 · 조작은 안 받음(누름은 위 캔버스). * 자료 = 지금 받는 GeoJSON 그대로(조각내기는 MapLibre 일꾼 스레드) · 지도가 없어도 자료는 담아 둠. * ========================================================================== */ import { Map as MapLibreMap, type FilterSpecification, type LayerSpecification, type SourceSpecification, } from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import { API_BASE_URL } from "@config/config_frontend"; import { themeColor } from "@ui/ui_template_palette"; import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; /** GL 로 옮긴 겹. */ export type GlLayerKey = "satellite" | "slope" | "cadastral" | "contour" | "road" | "stream"; export const GL_KEYS: readonly GlLayerKey[] = [ "satellite", "slope", "cadastral", "contour", "road", "stream", ]; /** 급경사 그림 — 주소 + 네 귀퉁이 경위도(왼쪽 위 · 오른쪽 위 · 오른쪽 아래 · 왼쪽 아래). */ export interface GlImage { url: string; corners: [[number, number], [number, number], [number, number], [number, number]]; } export interface GlBase { /** 자료 담기(지도 없어도 · 켤 때 올림) — null = 비움. */ setData: ( key: Exclude, data: GeoJsonCollection | GlImage | null, ) => void; setVisible: (key: GlLayerKey, on: boolean) => void; /** 등고선 주곡선 간격(m) — 캔버스와 같은 「화면 안 가닥 수」 규칙. */ setContourStep: (step: number) => void; /** 켬 = 지도를 `parent` 맨 앞에 만듦 · 끔 = 버림. */ enable: (parent: HTMLElement | null) => void; map: () => MapLibreMap | null; /** 화면을 따라감(경위도 가운데 · 줌 · 돌림 도) — 같은 화면에 바로 그림. */ camera: (center: [number, number], zoom: number, bearingDeg: number) => void; resize: () => void; } const HALO = "rgba(0, 0, 0, 0.55)"; const LEVEL = ["to-number", ["get", "등고수치"], 0]; /** 주곡선 = 간격 배수(계곡선도 — 캔버스처럼 계곡선은 두 번 겹쳐 짙음). */ const minorFilter = (step: number): FilterSpecification => ["==", ["%", LEVEL, step], 0] as unknown as FilterSpecification; /** 겹 차례(아래 → 위)와 꼴 — 캔버스 `D03_PlanAnalysis_UI_Layers` 의 색 · 굵기 · 투명도 그대로. */ function layerSpecs(step: number): Array { const contour = themeColor("--draw-map-contour", "#ffd68c"); const road = themeColor("--draw-map-road", "#f8fafc"); const stream = themeColor("--draw-map-stream", "#38bdf8"); const parcel = themeColor("--draw-map-parcel", "#f0abfc"); const round = { "line-join": "round", "line-cap": "round" } as const; return [ { key: "satellite", id: "satellite", type: "raster", source: "satellite" }, { key: "slope", id: "slope", type: "raster", source: "slope", paint: { "raster-opacity": 0.55, "raster-fade-duration": 0 }, }, { key: "cadastral", id: "cadastral", type: "line", source: "cadastral", layout: round, paint: { "line-color": parcel, "line-width": 0.8, "line-opacity": 0.8 }, }, { key: "contour", id: "contour-minor", type: "line", source: "contour", filter: minorFilter(step), layout: round, paint: { "line-color": contour, "line-width": 0.8, "line-opacity": 0.7 }, }, { key: "contour", id: "contour-index", type: "line", source: "contour", filter: ["==", ["%", LEVEL, 25], 0], layout: round, paint: { "line-color": contour, "line-width": 1.6, "line-opacity": 0.7 }, }, { key: "road", id: "road-halo", type: "line", source: "road", layout: round, paint: { "line-color": HALO, "line-width": 3.2 }, }, { key: "road", id: "road", type: "line", source: "road", layout: round, paint: { "line-color": road, "line-width": 1.6 }, }, { key: "stream", id: "stream", type: "line", source: "stream", layout: round, paint: { "line-color": stream, "line-width": ["case", ["==", ["get", "구분"], "세류"], 1, 2.4], }, }, ] as Array; } /** 겹 색 지문 — 밝음 · 어두움이 바뀌면 색을 다시. */ const themeKey = (): string => ["--draw-map-contour", "--draw-map-road", "--draw-map-stream", "--draw-map-parcel"] .map((name) => themeColor(name, "")) .join(); export function createGlBase(projectId: string): GlBase { const data = new Map(); const visible = new Map(); let step = 5; let theme = ""; let map: MapLibreMap | null = null; /** 첫 꼴이 섬(`isStyleLoaded` 는 조각 받는 동안 거짓이라 안 씀). */ let ready = false; const applied = new Map(); let container: HTMLDivElement | null = null; const source = (key: GlLayerKey): 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 }; return { type: "geojson", data: got as never, tolerance: 0.375 }; }; /** 자료 · 켬끔 · 색을 지도에 맞춤(지도가 다 서면) — 자료는 바뀐 겹만 다시 올림. */ function apply(): void { if (!map || !ready) return; const now = themeKey(); const specs = layerSpecs(step); const drop = (key?: GlLayerKey): void => { for (const spec of specs) if ((!key || spec.key === key) && map!.getLayer(spec.id)) map!.removeLayer(spec.id); }; if (now !== theme) { theme = now; drop(); // 밝음 · 어두움 = 색 다시 } for (const key of GL_KEYS) { 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(key)) 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"); }); } 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(); }, setContourStep(next) { if (next === step) return; step = next; if (map?.getLayer("contour-minor")) map.setFilter("contour-minor", minorFilter(step)); }, enable(parent) { if (!parent === !map) return; if (!parent) { map?.remove(); container?.remove(); map = container = null; ready = false; theme = ""; applied.clear(); return; } container = document.createElement("div"); container.className = "d03-plan__gl"; parent.prepend(container); map = new MapLibreMap({ container, style: { version: 8, sources: {}, layers: [] }, interactive: false, attributionControl: false, fadeDuration: 0, maxZoom: 24, renderWorldCopies: false, }); map.on("load", () => { ready = true; apply(); }); }, 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(), }; }