From 57ff92262bf4d1ef3e29b2f14baf46f63e5c2e18 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 19:08:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(D03=20=EC=A7=80=EB=8F=84=20=EA=B2=B9):=20?= =?UTF-8?q?=EB=B0=9B=EC=9D=80=20=EC=82=B0=EB=A6=BC=20=C2=B7=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EC=9E=90=EB=A3=8C=20=EA=B2=B9=20=EB=8B=A4=EC=84=AF?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=E2=80=94=20=EA=B5=AD=EC=9C=A0=EB=A6=BC?= =?UTF-8?q?=20=EC=9E=84=EB=8F=84=20=C2=B7=20=EC=82=B0=EB=A6=BC=EC=9E=85?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=C2=B7=20=EA=B2=BD=EC=A0=9C=EB=A6=BC=20?= =?UTF-8?q?=C2=B7=20=EC=83=9D=ED=83=9C=EC=9E=90=EC=97=B0=EB=8F=84=20=C2=B7?= =?UTF-8?q?=20=EC=82=B0=EC=82=AC=ED=83=9C=20=3D=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=A4=8C=EB=B3=84=20=EC=A1=B0=EA=B0=81=20=EA=B3=A0=EC=A0=95=20?= =?UTF-8?q?=EC=86=8C=EC=8A=A4=20=C2=B7=20=EB=B2=94=EB=A1=80=20=C2=B7=20?= =?UTF-8?q?=EB=B0=9B=EB=8A=94=20=EB=8F=99=EC=95=88=20=EC=84=9C=ED=81=B4=20?= =?UTF-8?q?(PLAN=20622-2=20=C2=B7=20=EC=9A=94=EA=B5=AC=20529)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 부품 tileSource(벡터 pbf · 그림 png) 덧 · 새 파일 UI_ForestLayers(꼴 · 소스 · 범례 · 토양 무리 색) - 산림입지도 그림 = forest_soil 조각(z10 이상) · 브이월드 받기 · 요약 그대로 · 범례 · 옛 캔버스 색 = 같은 토양 무리 색 - 산사태위험 GL = 조각 그림(받기 없음) · 옛 캔버스 = 받은 범위 한 장 그대로(끌 때 받음) - 새 겹 셋(국유림 임도 · 경제림 · 생태자연도) 처음 끔 · 옛 캔버스엔 안 보임 · 색 토큰 --draw-map-* 덧 · 아이콘 trees · plant 덧 - UI_Layers 688 → 680 줄(GL 글꼴 미리 받기 = UI_MapGl 로 · 브이월드 토양 GL 꼬리표 뺌) - 지킴 시험 test_622_2_forest_layers Co-Authored-By: Claude Opus 5.5 --- .../D03_PlanAnalysis_UI_ForestLayers.ts | 190 ++++++++++++++++++ .../D03_PlanAnalysis_UI_LayerDefs.ts | 26 ++- .../D03_PlanAnalysis_UI_LayerLegend.ts | 4 +- .../D03_PlanAnalysis_UI_Layers.ts | 40 ++-- D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts | 33 ++- resources/tester/test_622_2_forest_layers.py | 59 ++++++ ui_template/ui_template_gl_map.ts | 17 ++ ui_template/ui_template_icons.ts | 5 + ui_template/ui_template_theme.css | 11 +- 9 files changed, 348 insertions(+), 37 deletions(-) create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_ForestLayers.ts create mode 100644 resources/tester/test_622_2_forest_layers.py diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_ForestLayers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_ForestLayers.ts new file mode 100644 index 000000000..471dade28 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_ForestLayers.ts @@ -0,0 +1,190 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_ForestLayers.ts + * D03 받은 산림 · 환경 자료 겹(PLAN 622-2 · 요구 529) — 서버 줌별 조각(622-1)을 GL 고정 소스로(위성 · 등고선 615 꼴). + * 벡터 = 국유림 임도(forest_road · 간선 · 작업 · 산불 색 · 줌 따라 굵기) · 산림입지도(forest_soil · 토양 무리 색 · + * 반투명 · z10 아래 숨김) · 경제림(eco_forest · 국유 · 사유 테두리) / + * 그림 = 생태자연도(ecology) · 산사태위험지도(landslide · 옛 캔버스는 받은 범위 한 장 그대로). + * 조각만 쓰는 겹(`TILE_ONLY`) = 받기 없음 · 옛 캔버스(그래픽 카드 끔)엔 안 보임. + * 토양 무리 색(`soilColor`) = 조각(`code` = SLTP_CD 두 자리) · 브이월드 요약(기호 「B₁」) 같은 색 — 범례 · 옛 캔버스도 이것. + * 부르는 곳 = `D03_PlanAnalysis_UI_MapGl`(꼴 · 소스) · `UI_Layers`(받기 거름) · `UI_LayerLegend`(범례). + * ========================================================================== */ + +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 { PlanLayerKey } from "./D03_PlanAnalysis_UI_LayerDefs"; + +/** 조각만 쓰는 겹 — 받을 자료 없음(위성 포함). */ +const TILE_ONLY = ["satellite", "nationalRoad", "ecoForest", "ecology"] as const; +export type TileOnlyKey = (typeof TILE_ONLY)[number]; +export const tileOnly = (key: PlanLayerKey): 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" }, +]; +/** 생태자연도 범례 = 환경부 원도 색(조각 그림 그대로). */ +const ECOLOGY_LEGEND = [ + { label: "1등급", color: "--draw-map-ecology-1" }, + { label: "2등급", color: "--draw-map-ecology-2" }, + { label: "3등급", color: "--draw-map-ecology-3" }, + { 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; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts index 139f06f3f..aad87619f 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts @@ -30,6 +30,9 @@ export type PlanLayerKey = | "road" | "forestRoad" | "forestRoadFile" + | "nationalRoad" + | "ecoForest" + | "ecology" | "cadastral" | "zones" | "soils" @@ -80,6 +83,13 @@ export const GROUPS: readonly (readonly LayerDef[])[] = [ "올린 임도망도(산림청 시군구 SHP = 밝은 분홍 실선 · 왼쪽 「위치」 칸에서 올리고 묶음 고름 · 지움)", on: false, }, + { + key: "nationalRoad", + icon: "flag", + title: + "국유림 임도(산림청 전국 국가임도 · 간선 · 작업 · 산불 색 · 지방 · 민간임도 없음 · 참고용)", + on: false, + }, ], [ { key: "cadastral", icon: "borderAll", title: "지적도(참고용 · 법적 효력 없음)", on: false }, @@ -92,7 +102,19 @@ export const GROUPS: readonly (readonly LayerDef[])[] = [ { key: "soils", icon: "leaf", - title: "산림입지도(토양형 · 산림청 산림입지토양도 · 참고용)", + title: "산림입지도(토양형 · 산림청 산림입지토양도 1:25,000 · 줌 10 이상 · 참고용)", + on: false, + }, + { + key: "ecoForest", + icon: "trees", + title: "경제림육성단지(산림청 2014 · 국유 · 사유 테두리 · 참고용)", + on: false, + }, + { + key: "ecology", + icon: "plant", + title: "생태자연도(환경부 · 1~3 등급 · 별도관리 · 참고용)", on: false, }, { @@ -164,8 +186,6 @@ export const LINE_LAYERS: Record< zeroOrder: { label: "0차곡", color: "--draw-map-zero-order", width: 1.8, dash: [4, 3] }, }; export const LINE_KEYS = Object.keys(LINE_LAYERS) as LineLayerKey[]; -/** 토양형 색 — 받은 범위 안 코드 차례로 돌려 씀. */ -export const SOIL_COLORS = [1, 2, 3, 4, 5, 6].map((n) => `--draw-map-soil-${n}`); /** 도로 중 기존 임도로 짐작하는 구분. */ export const FOREST_ROAD_KINDS = new Set(["소로", "미분류"]); /** 물 면 = 도엽 수계(E) 면 셋. */ diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts index d07d5131a..e05c6eb77 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts @@ -24,6 +24,7 @@ import { type LineLayerKey, type PlanLayerKey, } from "./D03_PlanAnalysis_UI_LayerDefs"; +import { forestLegendRows } from "./D03_PlanAnalysis_UI_ForestLayers"; import { SLOPE_MAX_PCT, bandRatios, @@ -115,7 +116,8 @@ export function legendRows( ): LegendItem[][] { const rows: LegendItem[][] = []; if (on.slope && data.slope) rows.push(slopeRow(data.slope)); - if (on.landslide && data.landslide) rows.push(LANDSLIDE_LEGEND); + if (on.landslide) rows.push(LANDSLIDE_LEGEND); // GL = 조각(받기 없음 · 622-2) + rows.push(...forestLegendRows(on)); if (on.landslideZones && data.landslideZones) rows.push(landslideZoneRow(data.landslideZones)); if (on.zones && data.zones) { const items = Object.entries(data.zones.summary) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index c760cc8a0..825bee8a0 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -21,6 +21,8 @@ * GL 바탕(586-2 · 처음 = 켬 · 끝 단추 끔 = 옛 캔버스 · `aislo:pref:d03.canvas`) — 겹 스물 · 등고 글 · 지명을 * MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스 바탕 겹은 쉼(등고 간격 셈만) · 받기 · 범례는 같음 · * WebGL 못 씀 · 잃음 = 저절로 옛 캔버스 + 알림(취향은 안 바꿈). + * 받은 산림 · 환경 자료(622-2) = 국유림 임도 · 경제림 · 생태자연도(조각만 · 받기 없음) · 산림입지도 · 산사태위험(GL = 조각 · + * 옛 캔버스 = 지금 받기 그대로) — 꼴 · 범례 = `D03_PlanAnalysis_UI_ForestLayers`. * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; @@ -58,7 +60,6 @@ import { GROUPS, LAYERS, LINE_LAYERS, - SOIL_COLORS, WATER_KEYS, ZONE_COLORS, glImage, @@ -71,7 +72,8 @@ import { } from "./D03_PlanAnalysis_UI_LayerDefs"; import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend"; import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint"; -import { createGlBase, GL_FONT, type GlImage } from "./D03_PlanAnalysis_UI_MapGl"; +import { createGlBase, glFont, type GlImage } from "./D03_PlanAnalysis_UI_MapGl"; +import { soilColor, tileOnly, type TileOnlyKey } from "./D03_PlanAnalysis_UI_ForestLayers"; import { createSatLightRow, readSatLight } from "./D03_PlanAnalysis_UI_SatLight"; import { createSheetTiles, @@ -89,17 +91,14 @@ import { import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; const PREF_KEY = "aislo:pref:d03.layers"; -/** GL 글꼴 미리 받음 — 받기 전에 그린 글자는 대체 글꼴로 굳음(못 받아도 그림). */ -const glFont = (weight: string, text: string): Promise => - document.fonts.load(`${weight} 24px "${GL_FONT}"`, text || "가").catch(() => null); /** 옛 캔버스로 그림("1") — 처음 = GL. */ const CANVAS_PREF_KEY = "aislo:pref:d03.canvas"; /** 옛 캔버스 알림 — 위성 = GL 조각만(590-2). */ const NO_SATELLITE_NOTE = "옛 그림은 위성 바탕 없이 그림"; type DataKey = keyof Prepared; -/** 겹 → 받는 자료(임도 짐작 = 도로 자료). */ -const dataKey = (key: Exclude): DataKey => +/** 겹 → 받는 자료(임도 짐작 = 도로 자료 · 조각만 쓰는 겹 = 없음). */ +const dataKey = (key: Exclude): DataKey => key === "forestRoad" ? "road" : key; export interface PlanLayers { @@ -197,6 +196,7 @@ export function createPlanLayers(opts: { glOn = want && map.setGl(gl); if (!glOn) map.setGl(null); glButton.setAttribute("aria-pressed", String(glOn)); + if (!glOn) void Promise.all(LAYERS.map((def) => ensure(def.key))); // 산사태위험 한 장(622-2) sync(); map.redrawBase(); } @@ -212,8 +212,9 @@ export function createPlanLayers(opts: { legend.className = "d03-plan__legend"; legend.title = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이"; - /** 받음 — 위성 = GL 조각(받을 것 없음 · 옛 캔버스 = 위성 없음). */ - const loadedOf = (key: PlanLayerKey): boolean => key === "satellite" || !!data[dataKey(key)]; + /** 받음 — 위성 · 산림 조각 = GL 조각(받을 것 없음 · 옛 캔버스 = 안 그림) · 산사태위험 = GL 이면 조각(622-2). */ + const loadedOf = (key: PlanLayerKey): boolean => + tileOnly(key) || (glOn && key === "landslide") || !!data[dataKey(key)]; function sync(): void { for (const def of LAYERS) { @@ -222,8 +223,8 @@ export function createPlanLayers(opts: { button.disabled = !meta; const title = !meta ? `${def.title} — 지형 받기 뒤 보임` - : def.key === "satellite" && !glOn - ? `${def.title} — ${NO_SATELLITE_NOTE}` + : tileOnly(def.key) && !glOn + ? `${def.title} — 옛 그림(그래픽 카드 끔)엔 안 보임` : def.title; button.title = title; button.setAttribute("aria-label", title); @@ -287,7 +288,7 @@ export function createPlanLayers(opts: { /** 켜진 겹의 자료를 받음(한 번) — 실패 = 그 겹만 끔(기억은 안 바꿈 · 다음에 다시 받음) + 알림. */ function ensure(key: PlanLayerKey): Promise { if (!meta || !on[key] || loadedOf(key)) return Promise.resolve(); - const slot = key === "satellite" ? key : dataKey(key); + const slot = tileOnly(key) ? "satellite" : dataKey(key); const running = pending.get(slot); if (running) return running; const mine = areaTurn; @@ -301,7 +302,7 @@ export function createPlanLayers(opts: { if (mine !== areaTurn) return; const text = error instanceof Error ? error.message : String(error); for (const def of LAYERS) - if (def.key === key || (def.key !== "satellite" && dataKey(def.key) === slot)) + if (def.key === key || (!tileOnly(def.key) && dataKey(def.key) === slot)) on[def.key] = false; sync(); map.redraw(); @@ -543,21 +544,12 @@ export function createPlanLayers(opts: { } else if (key === "soils") { const soils = await fetchPlanSoils(projectId); got.soils = { - kinds: soils.summary.map((soil, i) => ({ + kinds: soils.summary.map((soil) => ({ layer: prepare(pick(soils, (props) => props["code"] === soil.code)), - color: SOIL_COLORS[i % SOIL_COLORS.length], + color: soilColor(soil.code), // GL 조각과 같은 무리 색(그림 = 조각 · 622-2) })), summary: soils.summary, }; - const colorOf = new Map( - soils.summary.map((soil, i) => [soil.code, got.soils!.kinds[i].color]), - ); - put( - "soils", - glTagged(soils, (props) => - themeColor(colorOf.get(props["code"] as never) ?? "", "#facc15"), - ), - ); } else if (key === "faults" || key === "lineaments" || key === "zeroOrder") { const kind = LINE_LAYERS[key].kind; // 0차곡 첫 셈 = 몇 초 — 늦으면 알림 diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts index ad78bfd08..d460e3e67 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts @@ -10,6 +10,7 @@ * 카메라 주인 = D03 화면(`ViewState` · 돌림) 하나 — 여기는 따라가기만 · 조작은 안 받음. * 자료 = 지금 받는 GeoJSON 그대로(조각내기는 MapLibre 일꾼 스레드) · 색 = 받는 쪽이 지물에 붙인 `_color`. * 등고선 = 줌별 벡터 조각(615 · 위성처럼 고정 소스 · 가는 선 = 모두 · 굵은 선 · 글 = `k` 1) · 받는 동안 서클. + * 받은 산림 · 환경 자료(622-2) = 같은 꼴 고정 소스 다섯(`D03_PlanAnalysis_UI_ForestLayers`) · 받는 동안 서클. * 그래픽 카드 · WebGL 이 없거나 잃으면 `onFail` — 부르는 쪽이 옛 캔버스로. * 지도 세우기 · 자료 올리기 · 카메라 = 공용 부품 `ui_template_gl_map`(589-2) · 여기는 겹 꼴만. * ========================================================================== */ @@ -26,6 +27,13 @@ import { import { themeColor } from "@ui/ui_template_palette"; import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { PlanLayerKey, RoadPick } from "./D03_PlanAnalysis_UI_LayerDefs"; +import { + forestAreaSpecs, + forestRasterSpecs, + forestRoadSpecs, + forestSources, + TRACKED_SOURCES, +} from "./D03_PlanAnalysis_UI_ForestLayers"; import { SAT_LIGHT_DEFAULT } from "./D03_PlanAnalysis_UI_SatLight"; export { GL_FONT, type GlImage }; @@ -34,6 +42,9 @@ export { GL_FONT, type GlImage }; export type GlSource = Exclude; const GL_FONT_BOLD = [`${GL_FONT} SemiBold`, GL_FONT]; +/** GL 글꼴 미리 받음 — 받기 전에 그린 글자는 대체 글꼴로 굳음(못 받아도 그림). */ +export const glFont = (weight: string, text: string): Promise => + document.fonts.load(`${weight} 24px "${GL_FONT}"`, text || "가").catch(() => null); export interface GlBase { /** 자료 담기(지도 없어도 · 켤 때 올림) — null = 비움. */ @@ -129,7 +140,7 @@ function layerSpecs(light: number, pick: RoadPick | null): GlSpec[] { const place = themeColor("--draw-map-place", "#f8fafc"); const landslideZone = themeColor("--draw-map-landslide-zone", "#e11d48"); const raster = ( - key: GlSource | "satellite", + key: "satellite" | "hillshade" | "slope", opacity: number, extra: Record = {}, ): GlSpec => @@ -147,8 +158,8 @@ function layerSpecs(light: number, pick: RoadPick | null): GlSpec[] { raster("satellite", 1, { "raster-brightness-max": light, "raster-saturation": SAT_SATURATION }), raster("hillshade", 1), // 위성 위 = 「곱하기」와 같은 검은 그늘 그림(받는 쪽이 바꿈) raster("slope", 0.55), - raster("landslide", 0.6), - ...fillEdge("soils", "soils", COLOR, 0.35, { color: COLOR, width: 1.4 }), + ...forestRasterSpecs(), // 산사태위험 · 생태자연도 = 조각 그림(622-2) + ...forestAreaSpecs(), // 산림입지도 · 경제림 = 벡터 조각 ...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, @@ -241,6 +252,7 @@ function layerSpecs(light: number, pick: RoadPick | null): GlSpec[] { [], { color: HALO, width: 4 }, ), + ...forestRoadSpecs(), // 국유림 임도 = 벡터 조각 // 고른 묶음 · 선 강조(605-2) — 같은 자료에서 걸러 한 겹 더 굵게 ...["-pick-halo", "-pick"].map( (tail, i) => @@ -348,7 +360,7 @@ const THEME_NAMES = [ /** 위성 채도(−1~1) — 조금 연하게. */ const SAT_SATURATION = -0.3; -/** `track` = 받는 일을 진행 서클에 셈(등고 조각 받는 동안). */ +/** `track` = 받는 일을 진행 서클에 셈(등고 · 산림 조각 받는 동안). */ export function createGlBase( projectId: string, track: (job: Promise) => Promise, @@ -358,12 +370,16 @@ export function createGlBase( let pick: RoadPick | null = null; const base = createGlMap({ specs: () => layerSpecs(light, pick), - fixed: { satellite: satelliteTileSource(projectId), contour: contourTileSource(projectId) }, + fixed: { + satellite: satelliteTileSource(projectId), + contour: contourTileSource(projectId), + ...forestSources(projectId), + }, themeNames: THEME_NAMES, className: "d03-plan__gl", onFail, }); - /** 등고 조각 받는 동안 서클 하나 — 다 받거나 지도가 쉬면(`idle`) · 지도를 버리면 끝. */ + /** 등고 · 산림 조각 받는 동안 서클 하나 — 다 받거나 지도가 쉬면(`idle`) · 지도를 버리면 끝. */ let settle: (() => void) | null = null; const done = (): void => { settle?.(); @@ -373,11 +389,12 @@ export function createGlBase( function watch(map: MapLibreMap): void { watched = map; map.on("sourcedataloading", (event) => { - if (event.sourceId !== "contour" || settle) return; + if (!TRACKED_SOURCES.includes(event.sourceId) || settle) return; void track(new Promise((resolve) => (settle = resolve))); }); map.on("sourcedata", (event) => { - if (event.sourceId === "contour" && map.isSourceLoaded("contour")) done(); + const loaded = (id: string) => !map.getSource(id) || map.isSourceLoaded(id); + if (TRACKED_SOURCES.includes(event.sourceId) && TRACKED_SOURCES.every(loaded)) done(); }); map.on("idle", done); } diff --git a/resources/tester/test_622_2_forest_layers.py b/resources/tester/test_622_2_forest_layers.py new file mode 100644 index 000000000..7b89f5d95 --- /dev/null +++ b/resources/tester/test_622_2_forest_layers.py @@ -0,0 +1,59 @@ +"""PLAN 622-2 — 예정노선 받은 산림 · 환경 자료 겹 다섯(국유림 임도 · 산림입지도 · 경제림 · 생태자연도 · 산사태) 화면 지킴. + +./venv/Scripts/python.exe -m pytest resources/tester/test_622_2_forest_layers.py -q +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +D03 = ROOT / "D03_PlanAnalysis" + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_fixed_sources_follow_plan_622_routes() -> None: + gl = _read(ROOT / "ui_template" / "ui_template_gl_map.ts") + assert "export function tileSource" in gl + assert "/plan/${path}/{z}/{x}/{y}.${ext}" in gl + forest = _read(D03 / "D03_PlanAnalysis_UI_ForestLayers.ts") + for name in ("forest_road", "forest_soil", "eco_forest"): + assert f'vt("{name}")' in forest + assert f'...vector("{name}")' in forest or f'"source-layer": "{name}"' in forest + for name in ("ecology", "landslide"): + assert f'wmt("{name}")' in forest + assert '`vt/${name}`, "pbf", 8, 15' in forest and '`wmt/${name}`, "png", 6, 19' in forest + assert "minzoom: 10" in forest # 산림입지도 z10 아래 숨김 + base = _read(D03 / "D03_PlanAnalysis_UI_MapGl.ts") + assert "...forestSources(projectId)" in base + assert 'raster("landslide"' not in base # 산사태 = 조각(한 장 GL 그림 없음) + assert "TRACKED_SOURCES.includes(event.sourceId)" in base # 받는 동안 서클 + + +def test_layer_list_five_off_by_default() -> None: + defs = _read(D03 / "D03_PlanAnalysis_UI_LayerDefs.ts") + icons = _read(ROOT / "ui_template" / "ui_template_icons.ts") + for key in ("nationalRoad", "soils", "ecoForest", "ecology", "landslide"): + entry = re.search(rf'key: "{key}",\s*icon: "(\w+)",[^}}]*on: (\w+)', defs) + assert entry and entry.group(2) == "false", key + assert re.search(rf"^\s+{entry.group(1)}:", icons, re.M), entry.group(1) + + +def test_colors_are_draw_map_tokens() -> None: + theme = _read(ROOT / "ui_template" / "ui_template_theme.css") + forest = _read(D03 / "D03_PlanAnalysis_UI_ForestLayers.ts") + for token in set(re.findall(r'"(--draw-map-[\w-]+)"', forest)): + assert f"{token}:" in theme, token + assert all(f"--draw-map-soil-{n}:" in theme for n in range(1, 7)) + assert not re.search(r"#[0-9a-fA-F]{6}\b(?!\")", forest) # 색 = 토큰(뒷값만 글) + + +def test_canvas_keeps_old_landslide_and_layers_file_not_grown() -> None: + layers = _read(D03 / "D03_PlanAnalysis_UI_Layers.ts") + assert len(layers.splitlines()) <= 688 + assert '(glOn && key === "landslide")' in layers # GL = 조각 · 받기 없음 + assert "if (!glOn) void Promise.all(LAYERS.map((def) => ensure(def.key)))" in layers + paint = _read(D03 / "D03_PlanAnalysis_UI_LayerPaint.ts") + assert "drawRaster(context, map, data.landslide);" in paint # 옛 캔버스 = 한 장 그대로 diff --git a/ui_template/ui_template_gl_map.ts b/ui_template/ui_template_gl_map.ts index bdf74c282..a1d72ff60 100644 --- a/ui_template/ui_template_gl_map.ts +++ b/ui_template/ui_template_gl_map.ts @@ -76,6 +76,23 @@ export function contourTileSource(projectId: string): SourceSpecification { }; } +/** 서버 줌별 조각(PLAN 622 · `…/plan//{z}/{x}/{y}.`) — pbf = 벡터(겹 이름 = 자료 이름) · png = 그림 256 px. */ +export function tileSource( + projectId: string, + path: string, + ext: "pbf" | "png", + minzoom: number, + maxzoom: number, +): SourceSpecification { + const tiles = [ + `${location.origin}${API_BASE_URL}/projects/${projectId}/plan/${path}/{z}/{x}/{y}.${ext}`, + ]; + const bounds: [number, number, number, number] = [124, 33, 132, 39]; + return ext === "pbf" + ? { type: "vector", tiles, minzoom, maxzoom, bounds } + : { type: "raster", tiles, tileSize: 256, minzoom, maxzoom, bounds }; +} + /** 느린 조작 잼(600-4) — `globalThis.__aisloPerf = true` 일 때만 `performance.measure(이름)` 으로 걸린 ms 를 남김(꺼져 있으면 그냥 실행). */ export function perfTime(name: string, run: () => T): T { if (!(globalThis as { __aisloPerf?: boolean }).__aisloPerf) return run(); diff --git a/ui_template/ui_template_icons.ts b/ui_template/ui_template_icons.ts index 89db9daf4..1648672f5 100644 --- a/ui_template/ui_template_icons.ts +++ b/ui_template/ui_template_icons.ts @@ -153,6 +153,11 @@ const PATHS = { "M12.802 2.165l5.575 2.389c.48 .206 .863 .589 1.07 1.07l2.388 5.574c.22 .512 .22 1.092 0 1.604l-2.389 5.575c-.206 .48 -.589 .863 -1.07 1.07l-5.574 2.388c-.512 .22 -1.092 .22 -1.604 0l-5.575 -2.389a2.036 2.036 0 0 1 -1.07 -1.07l-2.388 -5.574a2.036 2.036 0 0 1 0 -1.604l2.389 -5.575c.206 -.48 .589 -.863 1.07 -1.07l5.574 -2.388a2.036 2.036 0 0 1 1.604 0z|M12 8v4|M12 16h.01", // 585-3 — 연도 구역 마커(D03 도구줄 끌어 놓기) flag: "M5 5a5 5 0 0 1 7 0a5 5 0 0 0 7 0v9a5 5 0 0 1 -7 0a5 5 0 0 0 -7 0v-9z|M5 21v-7", + // 622-2 — 지도 겹(경제림 · 생태자연도) + trees: + "M16 5l3 3l-2 1l4 4l-3 1l4 4h-9|M15 21l0 -3|M8 13l-2 -2|M8 12l2 -2|M8 21v-13|M5.824 16a3 3 0 0 1 -2.743 -3.69a3 3 0 0 1 .304 -4.833a3 3 0 0 1 4.615 -3.707a3 3 0 0 1 4.614 3.707a3 3 0 0 1 .305 4.833a3 3 0 0 1 -2.919 3.695h-4z", + plant: + "M7 15h10v4a2 2 0 0 1 -2 2h-6a2 2 0 0 1 -2 -2z|M12 9a6 6 0 0 0 -6 -6h-3v2a6 6 0 0 0 6 6h3|M12 11a6 6 0 0 1 6 -6h3v1a6 6 0 0 1 -6 6h-3|M12 15l0 -6", } as const; export type IconName = keyof typeof PATHS; diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index c8f95f833..d11bcadc9 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -569,7 +569,7 @@ html.is-bm-route .m01lab-src__body * { --draw-map-zone-wetland: #2dd4bf; /* 습지보호지역 */ --draw-map-zone-wildlife: #eab308; /* 야생생물보호구역 */ --draw-map-zone-greenbelt: #f97316; /* 개발제한구역 */ - --draw-map-soil-1: #fef3c7; /* 산림입지도 토양형 — 받은 범위 안 코드 차례(흙빛 · 급경사 노랑과 다름) */ + --draw-map-soil-1: #fef3c7; /* 산림입지도 토양 무리 — 갈색 · 적황색 · 암적색 · 회갈색 화산회 · 침식 미숙 · 그 밖(흙빛 · 급경사 노랑과 다름) */ --draw-map-soil-2: #f5c08a; --draw-map-soil-3: #d9895a; --draw-map-soil-4: #bef264; @@ -585,6 +585,15 @@ html.is-bm-route .m01lab-src__body * { --draw-map-zero-order: #7dd3fc; /* 0차곡(지형 추정 골짜기 머리 · 점선) */ --draw-map-forest-road-file: #ff2fd0; /* 올린 임도망도(실선 · 검은 테 · 위성 위 밝은 분홍 · 짐작 = 주황 점선) */ --draw-map-landslide-zone: #e11d48; /* 산사태취약지역 점 · 필지 테두리 */ + --draw-map-national-road-main: #ff6b00; /* 국유림 임도 간선(실선 · 검은 테 · 짐작 = 주황 점선과 가름) */ + --draw-map-national-road-work: #ffd23f; /* 국유림 임도 작업 */ + --draw-map-national-road-fire: #ff3b3b; /* 국유림 임도 산불 */ + --draw-map-eco-national: #34d399; /* 경제림육성단지 국유(테두리) */ + --draw-map-eco-private: #a78bfa; /* 경제림육성단지 사유(테두리) */ + --draw-map-ecology-1: #2e9e4f; /* 생태자연도 1등급(환경부 원도 색 — 범례만) */ + --draw-map-ecology-2: #b5d96b; /* 2등급 */ + --draw-map-ecology-3: #f2f2c2; /* 3등급 */ + --draw-map-ecology-x: #9b7bd4; /* 별도관리지역 */ --draw-map-sketch: #ffe94d; /* 지도 위 그림(테마 안 따름 · 검은 테와 함께) — 긋기 미리보기 · 그리는 선 · 고름 강조 · 마커 */ --draw-map-measure: #ffffff; /* 거리재기 */ --draw-map-warn: #ff5a4d; /* 긋기 막힘(경고) */