/* ============================================================================= * map_forest_layers.ts * 공용 지도 — 받은 산림 · 환경 자료 겹(PLAN 622-2 · 624-2 · 요구 529 · 530) — 서버 줌별 조각(622-1)을 GL 고정 소스로(위성 · 등고선 615 꼴). * 벡터 = 국유림 임도(forest_road · 간선 · 작업 · 산불 색 · 줌 따라 굵기) · 산림입지도(forest_soil · 토양 무리 색 · * 반투명 · z10 아래 숨김) · 경제림(eco_forest · 국유 · 사유 테두리) / * 그림 = 생태자연도(ecology) · 산사태위험지도(landslide · 옛 캔버스는 받은 범위 한 장 그대로). * 조각만 쓰는 겹(`TILE_ONLY`) = 받기 없음 · 옛 캔버스(그래픽 카드 끔)엔 안 보임. * 토양 무리 색(`soilColor`) = 조각(`code` = SLTP_CD 두 자리) · 브이월드 요약(기호 「B₁」) 같은 색 — 범례 · 옛 캔버스도 이것. * 부르는 곳 = `map_layer_specs`(꼴) · D03 `UI_MapGl`(소스) · `UI_Layers`(받기 거름) · `map_legend`(범례). * ========================================================================== */ import type { ExpressionSpecification, SourceSpecification } from "maplibre-gl"; import type { LegendItem } from "@ui/ui_template_flow_legend"; import { tileSource, type GlSpec } from "@ui/ui_template_gl_map"; import { themeColor } from "@ui/ui_template_palette"; import type { MapLayerKey } from "./map_layer_list"; /** 조각만 쓰는 겹 — 받을 자료 없음(위성 포함). */ const TILE_ONLY = ["satellite", "nationalRoad", "ecoForest", "ecology"] as const; export type TileOnlyKey = (typeof TILE_ONLY)[number]; export const tileOnly = (key: MapLayerKey): key is TileOnlyKey => (TILE_ONLY as readonly string[]).includes(key); /** 받는 동안 서클에 세는 조각 소스(등고선 포함). */ export const TRACKED_SOURCES = [ "contour", "forest_road", "forest_soil", "eco_forest", "ecology", "landslide", ]; /** 고정 소스 — 벡터 z8~15(서버 줌별 단순화) · 그림 z6~19. */ export function forestSources(projectId: string): Record { const vt = (name: string) => tileSource(projectId, `vt/${name}`, "pbf", 8, 15); const wmt = (name: string) => tileSource(projectId, `wmt/${name}`, "png", 6, 19); return { forest_road: vt("forest_road"), forest_soil: vt("forest_soil"), eco_forest: vt("eco_forest"), ecology: wmt("ecology"), landslide: wmt("landslide"), }; } /** 토양형 코드(산림입지토양도 SLTP_CD) → 기호. 무리 = 갈색 · 적황색 · 암적색 · 회갈색 · 화산회 · 침식 · 미숙 · 그 밖(비산림). */ const SOIL_SYMBOLS: Record = { "01": "B1", "02": "B2", "03": "B3", "04": "B4", "05": "rB1", "06": "rB2", "07": "R1", "08": "R2", "09": "Y", "10": "DR1", "11": "DR2", "12": "DR3", "13": "DRb1", "14": "DRb2", "15": "GrB1", "16": "GrB2", "17": "Va1", "18": "Va2", "19": "Va3", "20": "Va4", "21": "Va-R1", "22": "Va-R2", "23": "Va-gr", "24": "Er1", "25": "Er2", "26": "Er-c", "27": "Im", "28": "Li", "29": "Bs", }; // prettier-ignore /** 무리 첫 코드(차례 = `--draw-map-soil-1..6`). */ const SOIL_GROUP_START = [1, 5, 10, 15, 24, 30]; const soilToken = (group: number): string => `--draw-map-soil-${group + 1}`; const SUB = /[₀-₉]/g; /** 토양형(조각 두 자리 코드 · 브이월드 기호 「B₁」) → 무리 색 값 · 모름 = 「그 밖」. */ export function soilColor(code: string): string { const plain = code.trim().replace(SUB, (c) => String(c.charCodeAt(0) - 0x2080)); const found = /^\d+$/.test(plain) ? Number(plain) : Number(Object.keys(SOIL_SYMBOLS).find((key) => SOIL_SYMBOLS[key] === plain) ?? 99); return soilToken(Math.max(SOIL_GROUP_START.filter((start) => found >= start).length - 1, 0)); } /** 임도 사업 구분(`kind` 1 간선 · 2 작업 · 3 산불) 색 값. */ const ROAD_KINDS = [ { kind: 1, label: "간선", color: "--draw-map-national-road-main" }, { kind: 2, label: "작업", color: "--draw-map-national-road-work" }, { kind: 3, label: "산불", color: "--draw-map-national-road-fire" }, ]; const ECO_KINDS = [ { own: "국유", color: "--draw-map-eco-national" }, { own: "사유", color: "--draw-map-eco-private" }, ]; /** 생태자연도 범례 = 환경부 WMS 그림 색(3등급 = 칠 없음). */ const ECOLOGY_LEGEND = [ { label: "1등급", color: "--draw-map-ecology-1" }, { label: "2등급", color: "--draw-map-ecology-2" }, { label: "별도관리지역", color: "--draw-map-ecology-x" }, ]; const HALO = "rgba(0, 0, 0, 0.55)"; const ROUND = { "line-join": "round", "line-cap": "round" } as const; const zoomWidth = (z8: number, z12: number, z15: number): ExpressionSpecification => ["interpolate", ["linear"], ["zoom"], 8, z8, 12, z12, 15, z15] as ExpressionSpecification; type Spec = GlSpec; const raster = (key: "landslide" | "ecology", opacity: number): Spec => ({ key, id: key, type: "raster", source: key, paint: { "raster-opacity": opacity, "raster-fade-duration": 0 }, }) as Spec; /** 그림 겹(산사태위험 · 생태자연도) — 아래 차례(급경사 위). */ export const forestRasterSpecs = (): Spec[] => [raster("landslide", 0.6), raster("ecology", 0.55)]; /** 면 겹(산림입지도 · 경제림) — 보호구역 아래. */ export function forestAreaSpecs(): Spec[] { const soilAt = (group: number): string => themeColor(soilToken(group), "#e879f9"); const soil = [ "step", ["to-number", ["get", "code"], 99], soilAt(0), ...SOIL_GROUP_START.slice(1).flatMap((start, i) => [start, soilAt(i + 1)]), ] as ExpressionSpecification; const eco = [ "match", ["get", "own"], ...ECO_KINDS.flatMap(({ own, color }) => [own, themeColor(color, "#34d399")]), themeColor(ECO_KINDS[1].color, "#a78bfa"), ] as unknown as ExpressionSpecification; const vector = (source: string) => ({ source, "source-layer": source }); return [ { key: "soils", id: "soils", type: "fill", ...vector("forest_soil"), minzoom: 10, paint: { "fill-color": soil, "fill-opacity": 0.35 }, } as Spec, { key: "soils", id: "soils-edge", type: "line", ...vector("forest_soil"), minzoom: 10, layout: ROUND, paint: { "line-color": soil, "line-width": 0.8, "line-opacity": 0.9 }, } as Spec, { key: "ecoForest", id: "ecoForest", type: "line", ...vector("eco_forest"), layout: ROUND, paint: { "line-color": eco, "line-width": zoomWidth(1, 1.8, 2.4) }, } as Spec, ]; } /** 국유림 임도 선 — 검은 테 + 사업 구분 색(올린 임도망도 위). */ export function forestRoadSpecs(): Spec[] { const color = [ "match", ["get", "kind"], ...ROAD_KINDS.flatMap(({ kind, color }) => [kind, themeColor(color, "#ff6b00")]), themeColor(ROAD_KINDS[0].color, "#ff6b00"), ] as unknown as ExpressionSpecification; const line = (id: string, paint: Record): Spec => ({ key: "nationalRoad", id, type: "line", source: "forest_road", "source-layer": "forest_road", layout: ROUND, paint, }) as Spec; return [ line("nationalRoad-halo", { "line-color": HALO, "line-width": zoomWidth(2.2, 3.6, 5.6) }), line("nationalRoad", { "line-color": color, "line-width": zoomWidth(1, 2, 3.6) }), ]; } /** 범례 — 켠 조각 겹(산사태위험 = 범례 자리 `LANDSLIDE_LEGEND` 따로). */ export function forestLegendRows(on: Record): LegendItem[][] { const item = (label: string, color: string, kind?: LegendItem["kind"]): LegendItem => ({ label, color: `var(${color})`, ...(kind ? { kind } : {}), }); const rows: LegendItem[][] = []; if (on.nationalRoad) rows.push(ROAD_KINDS.map(({ label, color }) => item(`국유림 임도 ${label}`, color, "line"))); if (on.ecoForest) rows.push(ECO_KINDS.map(({ own, color }) => item(`경제림 ${own}`, color, "line"))); if (on.ecology) rows.push(ECOLOGY_LEGEND.map(({ label, color }) => item(`생태자연도 ${label}`, color))); return rows; }