/* ============================================================================= * D03_PlanAnalysis_UI_MapGl.ts * D03 GL 바탕(PLAN 586-1 · 586-2 · 요구 445 B · 448) — 바탕 겹 스물을 MapLibre(WebGL · BSD-3)로: * 위성 조각(서버 대리 · 서버 기억 z6~19) · 그림 겹(음영 · 급경사 · 산사태위험 = image) · * 면 겹(산림입지 · 보호구역 · 띠 · 물 면 · 건물 = fill) · 선 겹(지적 · 등고선 · 도로 · 임도 짐작 · 올린 임도망도 · * 세류 · 단층 · 지질구조선 · 0차곡 · 산사태취약 필지 = line · 점선 포함) · 점(산사태취약 · 지명 = circle) · * 글(등고 수치 · 지명 = symbol · 겹치면 뺌 · 글꼴 = 화면 Noto Sans KR(OFL · 자체 보관)을 그 자리에서 그림 — * 꼴에 글꼴 주소가 없으면 MapLibre 가 모든 글자를 화면 글꼴로 그림 · 글꼴 조각 파일 없음). * 그은 선 · 재기 · 고르기 · 누름 = 위 캔버스 그대로(위에 겹침). * 카메라 주인 = D03 화면(`ViewState` · 돌림) 하나 — 여기는 따라가기만 · 조작은 안 받음. * 자료 = 지금 받는 GeoJSON 그대로(조각내기는 MapLibre 일꾼 스레드) · 색 = 받는 쪽이 지물에 붙인 `_color`. * 그래픽 카드 · WebGL 이 없거나 잃으면 `onFail` — 부르는 쪽이 옛 캔버스로. * ========================================================================== */ import { Map as MapLibreMap, type ExpressionSpecification, 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"; import type { PlanLayerKey } from "./D03_PlanAnalysis_UI_LayerDefs"; /** 자료 소스(임도 짐작 = 도로 소스를 거름 · 위성 = 조각). */ export type GlSource = Exclude; /** 그림 겹 — 주소 + 네 귀퉁이 경위도(왼쪽 위 · 오른쪽 위 · 오른쪽 아래 · 왼쪽 아래). */ export interface GlImage { url: string; corners: [[number, number], [number, number], [number, number], [number, number]]; } /** 글꼴 — 화면 `@font-face`(ui_template/fonts · OFL) 이름. 미리 받아 둬야 첫 글자가 대체 글꼴로 안 굳음. */ export const GL_FONT = "Noto Sans KR"; const GL_FONT_BOLD = [`${GL_FONT} SemiBold`, GL_FONT]; export interface GlBase { /** 자료 담기(지도 없어도 · 켤 때 올림) — null = 비움. */ setData: (source: GlSource, data: GeoJsonCollection | GlImage | null) => void; setVisible: (key: PlanLayerKey, on: boolean) => void; /** 등고선 주곡선 간격(m) — 캔버스와 같은 「화면 안 가닥 수」 규칙. */ setContourStep: (step: number) => void; /** 켬 = 지도를 `parent` 맨 앞에 만듦(참 = 섬 · 거짓 = WebGL 못 씀) · null = 버림. */ enable: (parent: HTMLElement | null) => boolean; map: () => MapLibreMap | null; /** 화면을 따라감(경위도 가운데 · 줌 · 돌림 도) — 같은 화면에 바로 그림. */ camera: (center: [number, number], zoom: number, bearingDeg: number) => void; resize: () => void; } type GlSpec = LayerSpecification & { key: PlanLayerKey; source: GlSource | "satellite" }; const HALO = "rgba(0, 0, 0, 0.55)"; const ROUND = { "line-join": "round", "line-cap": "round" } as const; const COLOR: ExpressionSpecification = ["get", "_color"]; const LEVEL: ExpressionSpecification = ["to-number", ["get", "등고수치"], 0]; /** 주곡선 = 간격 배수(계곡선도 — 캔버스처럼 계곡선은 두 번 겹쳐 짙음). */ const minorFilter = (step: number): FilterSpecification => ["==", ["%", LEVEL, step], 0] as unknown as FilterSpecification; const INDEX = ["==", ["%", LEVEL, 25], 0] as unknown as FilterSpecification; /** 캔버스 점선(px) → MapLibre 점선(선 굵기 배수). */ const dash = (px: number[], width: number): number[] => px.map((v) => v / width); /** 선 겹 — 검은 테두리 + 선(점선이면 둘 다 같은 px 무늬). */ function haloLine( key: PlanLayerKey, source: GlSource, color: string, width: number, pattern: number[] = [], halo = { color: HALO, width: width + 1.6 }, filter?: FilterSpecification, ): GlSpec[] { const line = (id: string, paint: Record, w: number): GlSpec => ({ key, id, type: "line", source, ...(filter ? { filter } : {}), layout: ROUND, paint: { ...paint, "line-width": w, ...(pattern.length ? { "line-dasharray": dash(pattern, w) } : {}), }, }) as GlSpec; return [ line(`${key}-halo`, { "line-color": halo.color }, halo.width), line(key, { "line-color": color }, width), ]; } /** 면 겹 — 채움 + 테두리(캔버스 `fillLayer` 처럼 테두리 투명도 0.9). */ function fillEdge( key: PlanLayerKey, source: GlSource, fill: string | ExpressionSpecification, opacity: number | ExpressionSpecification, edge: { color: string | ExpressionSpecification; width: number; dash?: number[] }, ): GlSpec[] { return [ { key, id: key, type: "fill", source, paint: { "fill-color": fill, "fill-opacity": opacity }, } as GlSpec, { key, id: `${key}-edge`, type: "line", source, layout: ROUND, paint: { "line-color": edge.color, "line-width": edge.width, "line-opacity": 0.9, ...(edge.dash ? { "line-dasharray": dash(edge.dash, edge.width) } : {}), }, } as GlSpec, ]; } /** 겹 차례(아래 → 위)와 꼴 — 캔버스 `D03_PlanAnalysis_UI_Layers` 의 색 · 굵기 · 투명도 · 차례 그대로. */ function layerSpecs(step: number): GlSpec[] { const contour = themeColor("--draw-map-contour", "#ffd68c"); const place = themeColor("--draw-map-place", "#f8fafc"); const landslideZone = themeColor("--draw-map-landslide-zone", "#e11d48"); const raster = (key: GlSource | "satellite", opacity: number): GlSpec => ({ key, id: key, type: "raster", source: key, paint: { "raster-opacity": opacity, "raster-fade-duration": 0 }, }) as GlSpec; const lineKind = (key: "faults" | "lineaments" | "zeroOrder", width: number, pattern: number[]) => haloLine(key, key, themeColor(LINE_COLOR[key], "#f43f5e"), width, pattern); return [ raster("satellite", 1), raster("hillshade", 1), // 위성 위 = 「곱하기」와 같은 검은 그늘 그림(받는 쪽이 바꿈) raster("slope", 0.55), raster("landslide", 0.6), ...fillEdge("soils", "soils", COLOR, 0.35, { color: COLOR, width: 1.4 }), ...fillEdge("zones", "zones", COLOR, 0.22, { color: COLOR, width: 1.6 }), ...fillEdge("bands", "bands", COLOR, ["case", ["==", ["get", "_band"], "road"], 0.16, 0.18], { color: COLOR, width: 1.2, dash: [6, 4], }), { key: "cadastral", id: "cadastral", type: "line", source: "cadastral", layout: ROUND, paint: { "line-color": themeColor("--draw-map-parcel", "#f0abfc"), "line-width": 0.8, "line-opacity": 0.8, }, } as GlSpec, ...((): GlSpec[] => { const water = themeColor("--draw-map-water", "#0ea5e9"); return fillEdge("water", "water", water, 0.45, { color: water, width: 1 }); })(), ...fillEdge("building", "building", themeColor("--draw-map-building", "#fb7185"), 0.7, { color: HALO, width: 0.6, }), { 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 }, } as GlSpec, { key: "contour", id: "contour-index", type: "line", source: "contour", filter: INDEX, layout: ROUND, paint: { "line-color": contour, "line-width": 1.6, "line-opacity": 0.7 }, } as GlSpec, { key: "contour", id: "contour-label", type: "symbol", source: "contour", filter: INDEX, layout: { "symbol-placement": "line", "symbol-spacing": 320, "text-field": ["to-string", LEVEL], "text-font": [GL_FONT], "text-size": 10, "text-max-angle": 30, "text-padding": 2, }, paint: { "text-color": contour, "text-halo-color": "rgba(0, 0, 0, 0.6)", "text-halo-width": 1.5, }, } as GlSpec, ...haloLine("road", "road", themeColor("--draw-map-road", "#f8fafc"), 1.6, [], { color: HALO, width: 3.2, }), ...haloLine( "forestRoad", "road", themeColor("--draw-map-forest-road", "#f59e0b"), 2, [7, 4], { color: HALO, width: 3.6 }, [ "in", ["get", "도로구분"], ["literal", ["소로", "미분류"]], ] as unknown as FilterSpecification, ), ...haloLine( "forestRoadFile", "forestRoadFile", themeColor("--draw-map-forest-road-file", "#ea580c"), 2.4, [], { color: HALO, width: 4 }, ), { key: "stream", id: "stream", type: "line", source: "stream", layout: ROUND, paint: { "line-color": themeColor("--draw-map-stream", "#38bdf8"), "line-width": ["case", ["==", ["get", "구분"], "세류"], 1, 2.4], }, } as GlSpec, ...lineKind("faults", 2.4, []), ...lineKind("lineaments", 1.8, [8, 4]), ...lineKind("zeroOrder", 1.8, [4, 3]), // 산사태취약 — 필지 = 「이 필지 안 어딘가」 테두리만 · 점 = 지정 자리 ...haloLine( "landslideZones", "landslideZones", landslideZone, 1.8, [6, 3], { color: "rgba(255, 255, 255, 0.7)", width: 3.2 }, ["!=", ["geometry-type"], "Point"] as unknown as FilterSpecification, ), { key: "landslideZones", id: "landslideZones-point", type: "circle", source: "landslideZones", filter: ["==", ["geometry-type"], "Point"], paint: { "circle-radius": 5, "circle-color": landslideZone, "circle-stroke-color": "#ffffff", "circle-stroke-width": 1.5, }, } as GlSpec, { key: "place", id: "place-dot", type: "circle", source: "place", paint: { "circle-radius": 2.5, "circle-color": place }, } as GlSpec, { key: "place", id: "place", type: "symbol", source: "place", layout: { "text-field": ["get", "name"], "text-font": GL_FONT_BOLD, "text-size": 11, "text-anchor": "bottom", "text-offset": [0, -0.36], "text-padding": 2, }, paint: { "text-color": place, "text-halo-color": "rgba(0, 0, 0, 0.7)", "text-halo-width": 1.5, }, } as GlSpec, ]; } /** 선 겹 색 값(캔버스 `LINE_LAYERS` 와 같음). */ const LINE_COLOR = { faults: "--draw-map-fault", lineaments: "--draw-map-lineament", zeroOrder: "--draw-map-zero-order", } as const; /** 겹 색 지문 — 밝음 · 어두움이 바뀌면 색을 다시. */ const themeKey = (): string => ["--draw-map-contour", "--draw-map-road", "--draw-map-stream", "--draw-map-parcel"] .map((name) => themeColor(name, "")) .join(); const SOURCES: readonly (GlSource | "satellite")[] = [ "satellite", "hillshade", "slope", "landslide", "soils", "zones", "bands", "cadastral", "water", "building", "contour", "road", "forestRoadFile", "stream", "faults", "lineaments", "zeroOrder", "landslideZones", "place", ]; export function createGlBase(projectId: string, onFail: (why: string) => void): 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: GlSource | "satellite"): 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 }; // 묶음 꼴 표시가 없는 자료(여러 겹을 합친 것 등)도 받게 다시 싸 줌 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 = layerSpecs(step); 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 SOURCES) { 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(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(); }, setContourStep(next) { if (next === step) return; step = next; if (map?.getLayer("contour-minor")) map.setFilter("contour-minor", minorFilter(step)); }, enable(parent) { if (!parent) { teardown(); return false; } if (map) return true; container = document.createElement("div"); container.className = "d03-plan__gl"; 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("D03 GL 바탕 못 켬", error); onFail("브라우저가 WebGL 을 열지 못함"); return false; } map.on("load", () => { ready = true; apply(); }); map.on("webglcontextlost", () => onFail("그래픽 카드 문맥을 잃음")); return true; }, 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(), }; }