diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index d2e08af29..bd9be9c4b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -2,7 +2,7 @@ * D03_PlanAnalysis_Api.ts * D03 분석 화면 서버 요청(PLAN 563-1) — 지형 받기 범위 · 받기 · 진행 · * 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 응답에 D02 시점 · 종점) · - * 지도 겹(569 — 지적도 · 도엽 겹 GeoJSON 경위도 · 급경사 그림 · 581 — 음영기복 · 보호구역 · 띠). + * 지도 겹(569 — 지적도 · 도엽 겹 GeoJSON 경위도 · 급경사 그림 · 581 — 음영기복 · 보호구역 · 띠 · 산림입지도). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -162,6 +162,14 @@ export interface PlanZones extends GeoJsonCollection { export const fetchPlanZones = (projectId: string): Promise => request(`${base(projectId)}/layers/zones`); +/** 산림입지도(581-2) — 지물 `code` · `name` · `area_m2` + 토양형별 개수 · 넓이(코드 차례). */ +export interface PlanSoils extends GeoJsonCollection { + summary: Array<{ code: string; name: string; count: number; area_m2: number }>; +} + +export const fetchPlanSoils = (projectId: string): Promise => + request(`${base(projectId)}/layers/soils`); + /** 계류 100 m · 도로 300 m 띠(581-1) — 받은 범위 안 넓이 · 비율. */ export interface PlanBand { dist_m: number; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py index 55de83314..6dbd2492b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py @@ -10,6 +10,9 @@ - 보호 · 규제 구역 = 브이월드 WFS 여덟 종(겹 이름 = `_분석_440-C` 표 1) · 받은 범위로 잘라 종류 · 이름 · 넓이만 · 저장 = `processed/보호구역_bounds.geojson` + 곁 열쇠 · 종류 하나 실패 = 그 종류만 빠짐 +- 산림입지도 = 브이월드 WFS `lt_c_fsdifrsts`(원자료 산림청 산림입지토양도 · PLAN 581-2) · + 받은 범위로 잘라 토양형 코드 · 이름 · 넓이만 · + 저장 = `processed/산림입지도_bounds.geojson` + 곁 열쇠 - 계류 100 m · 도로 300 m 띠 = 도엽 하천중심선(세류 뺌) · 도로중심선(미분류 · 소로 · 면리간도로 뺌) 합친 띠 · 받은 범위로 자름 · 파생 저장본(열쇠 = 두 파일 지문) @@ -316,12 +319,27 @@ def _transforms(epsg: int) -> tuple[Any, Any]: return to_m, to_ll +def _clip_metric(feature: dict[str, Any], frame: Any, to_m: Any) -> Any: + """지물 면(경위도) → 작업 좌표계 m 로 바꿔 범위로 자름 · 못 읽거나 빈 것 = None.""" + from shapely.geometry import shape + from shapely.ops import transform + + try: + metric = transform(to_m, shape(feature["geometry"])) + except (KeyError, TypeError, ValueError, AttributeError): + return None + if not metric.is_valid: + metric = metric.buffer(0) + part = metric.intersection(frame) + return None if part.is_empty or part.area <= 0 else part + + def clip_zones( found: dict[str, list[dict[str, Any]]], box: Box, epsg: int ) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]: """종류별 지물 → 받은 범위로 자른 지물(종류 · 이름) + 종류별 {이름, 개수, 넓이 m²}.""" from shapely.geometry import box as rect - from shapely.geometry import mapping, shape + from shapely.geometry import mapping from shapely.ops import transform to_m, to_ll = _transforms(epsg) @@ -331,14 +349,8 @@ def clip_zones( for kind, label, _ in ZONE_KINDS: count, area = 0, 0.0 for feature in dedup_parcels(found.get(kind) or []): - try: - metric = transform(to_m, shape(feature["geometry"])) - except (KeyError, TypeError, ValueError, AttributeError): - continue - if not metric.is_valid: - metric = metric.buffer(0) - part = metric.intersection(frame) - if part.is_empty or part.area <= 0: + part = _clip_metric(feature, frame, to_m) + if part is None: continue props = feature.get("properties") or {} name = next((str(props[k]) for k in ZONE_NAME_PROPS if props.get(k)), label) @@ -394,6 +406,73 @@ def ensure_zones(processed: Path, box: Box, epsg: int) -> dict[str, Any]: return {"path": path, "fetched": True, "failed": failed} +# ── 산림입지도 ────────────────────────────────────────── + +SOIL_TYPENAME = "lt_c_fsdifrsts" +SOIL_FILE = "산림입지도_bounds.geojson" +SOIL_KEY = "산림입지도_bounds.key.json" +SOIL_VERSION = 1 + + +def clip_soils( + found: list[dict[str, Any]], box: Box, epsg: int +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """산림입지도 지물 → 받은 범위로 자른 지물(토양형 코드 · 이름 · 넓이) + 토양형별 {코드, 이름, + 개수, 넓이 m²}(코드 차례). 이름 없으면 토양형 글자 그대로.""" + from shapely.geometry import box as rect + from shapely.geometry import mapping + from shapely.ops import transform + + to_m, to_ll = _transforms(epsg) + frame = rect(*box) + features: list[dict[str, Any]] = [] + summary: dict[str, dict[str, Any]] = {} + for feature in dedup_parcels(found): + part = _clip_metric(feature, frame, to_m) + if part is None: + continue + props = feature.get("properties") or {} + code = str(props.get("toyanghyun") or props.get("sltp_cd") or "?") + name = str(props.get("name") or code) + row = summary.setdefault( + code, + {"code": code, "sort": str(props.get("sltp_cd") or code), "name": name, "count": 0}, + ) + row["count"] += 1 + row["area_m2"] = row.get("area_m2", 0.0) + part.area + features.append( + { + "type": "Feature", + "geometry": mapping(transform(to_ll, part)), + "properties": {"code": code, "name": name, "area_m2": round(part.area, 1)}, + } + ) + rows = sorted(summary.values(), key=lambda row: (row["sort"], row["code"])) + for row in rows: + del row["sort"] + row["area_m2"] = round(row["area_m2"], 1) + return features, rows + + +def ensure_soils(processed: Path, box: Box, epsg: int) -> dict[str, Any]: + """산림입지도 — 열쇠 상자가 덮으면 그대로 · 아니면 범위 통째로 다시 받음 · 실패 = 오류.""" + path, key_path = processed / SOIL_FILE, processed / SOIL_KEY + key = _read_json(key_path) or {} + if path.is_file() and key.get("version") == SOIL_VERSION and _covers(key.get("box"), box): + return {"path": path, "fetched": False} + got, failed = fetch_tile(box, epsg, 0, SOIL_TYPENAME) + if failed: + raise RuntimeError("산림입지도를 받지 못했습니다 — 브이월드 WFS 실패") + features, summary = clip_soils(got, box, epsg) + processed.mkdir(parents=True, exist_ok=True) + atomic_write_json(path, {"type": "FeatureCollection", "features": features, "summary": summary}) + atomic_write_json( + key_path, {"version": SOIL_VERSION, "box": [float(v) for v in box], "epsg": int(epsg)} + ) + logger.info("D 산림입지도 받기: 지물 %d · 토양형 %d", len(features), len(summary)) + return {"path": path, "fetched": True} + + # ── 계류 · 도로 띠 ─────────────────────────────────────── BAND_KIND = "plan_band" diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 7499bd6cc..b97221240 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -4,7 +4,7 @@ - `POST …/plan/terrain` — 사업 전체 지형 한 벌 받기(뒤에서 · 누를 때만) · 끝나면 `surface_models` 행 - `GET …/plan/progress` — 일의 진행률 - `GET …/plan/layers/sheet?key=` · `…/cadastral` · `…/raster?kind=slope|hillshade` · - `…/zones` · `…/bands` — 지도 겹(PLAN 569-1 · 581-1) + `…/zones` · `…/soils` · `…/bands` — 지도 겹(PLAN 569-1 · 581-1 · 581-2) 범위 = 전체 시점 · 종점(D02 정본 `load_base`) + 그은 점(`extra`) 상자 + 여유. 노선 정본(손 그은 선)은 @@ -223,6 +223,18 @@ async def plan_layer_zones(project_id: UUID, request: Request) -> Response: return cached_file_response(request, got["path"], "application/geo+json") +@router.get("/{project_id}/plan/layers/soils", response_model=None) +async def plan_layer_soils(project_id: UUID, request: Request) -> Response: + """산림입지도 GeoJSON(토양형 · `summary` = 토양형별 개수 · 넓이) — 범위 밖이면 받음.""" + root, epsg, box = await _layer_root(project_id) + processed = root / terrain.STAGE / "processed" + try: + got = await asyncio.to_thread(layers.ensure_soils, processed, box, epsg) + except RuntimeError as error: + raise HTTPException(status_code=502, detail=str(error)) from error + return cached_file_response(request, got["path"], "application/geo+json") + + @router.get("/{project_id}/plan/layers/bands", response_model=None) async def plan_layer_bands(project_id: UUID) -> dict[str, Any]: """계류 100 m · 도로 300 m 띠(넓이 · 받은 범위 비율) — 두 도엽 겹 지문 같으면 저장본.""" diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts index 43b19d6d7..e5bde1e2c 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts @@ -1,6 +1,6 @@ /* ============================================================================= * D03_PlanAnalysis_UI_LayerDefs.ts - * D03 바탕 겹 정의(PLAN 569-2 · 581-1) — 겹 단추 묶음 · 처음 값 · 보호구역 색 · 면 겹 채우기. + * D03 바탕 겹 정의(PLAN 569-2 · 581-1 · 581-2) — 겹 단추 묶음 · 처음 값 · 보호구역 · 토양형 색 · 면 겹 채우기. * 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기 차례 · 범례). * ========================================================================== */ @@ -22,6 +22,7 @@ export type PlanLayerKey = | "forestRoad" | "cadastral" | "zones" + | "soils" | "bands" | "building" | "place"; @@ -60,6 +61,12 @@ export const GROUPS: readonly (readonly LayerDef[])[] = [ title: "보호 · 규제 구역(브이월드 여덟 종 · 참고용)", on: false, }, + { + key: "soils", + icon: "leaf", + title: "산림입지도(토양형 · 산림청 산림입지토양도 · 참고용)", + on: false, + }, { key: "bands", icon: "borderOuter", @@ -82,6 +89,8 @@ export const ZONE_COLORS: Record = { wildlife: "--draw-map-zone-wildlife", greenbelt: "--draw-map-zone-greenbelt", }; +/** 토양형 색 — 받은 범위 안 코드 차례로 돌려 씀. */ +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_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index 166f4eb72..cee75dca3 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -1,14 +1,14 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Layers.ts - * D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 581-1 · 요구 395-2 · 440-8 · 442) — 지도 왼쪽 위 아이콘 한 줄 + * D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 581-1 · 581-2 · 요구 395-2 · 440-8 · 442) — 지도 왼쪽 위 아이콘 한 줄 * (공용 지도 도구줄 `createMapTools` · 넘치면 가로 밀기 571-1 · 오른쪽 평면 도구줄과 따로). * * 겹 = 바탕(위성 · 음영기복 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 급경사 색) · * 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 기존 임도 짐작(소로 · 미분류)) · - * 구역(지적도 · 보호 · 규제 구역 여덟 종 · 계류 100 m · 도로 300 m 띠 · 건물 · 지명). + * 구역(지적도 · 보호 · 규제 구역 여덟 종 · 산림입지도(토양형) · 계류 100 m · 도로 300 m 띠 · 건물 · 지명). * 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억. * 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음 · 받기 실패 = 그 겹만 꺼짐 + 알림. - * 그리기 차례(아래 → 위) = 위성(`scene.image`) → 음영기복 → 급경사 → 보호구역 → 띠 → 지적 → 물 면 → + * 그리기 차례(아래 → 위) = 위성(`scene.image`) → 음영기복 → 급경사 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 → * 건물 → 등고선 → 도로 → 임도 짐작 → 세류 → 지명 → 그은 선. * 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억. * 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래). @@ -44,15 +44,18 @@ import { fetchPlanCadastral, fetchPlanRaster, fetchPlanSheetLayer, + fetchPlanSoils, fetchPlanZones, type PlanBand, type PlanRaster, + type PlanSoils, type PlanZones, } from "./D03_PlanAnalysis_Api"; import { FOREST_ROAD_KINDS, GROUPS, LAYERS, + SOIL_COLORS, WATER_KEYS, ZONE_COLORS, fillLayer, @@ -76,6 +79,8 @@ interface Prepared { hillshade: { raster: PlanRaster; image: HTMLImageElement }; water: PreparedLayer; zones: { kinds: Map; summary: PlanZones["summary"] }; + /** 토양형(코드 차례) → 지물 · 색 값. */ + soils: { kinds: Array<{ layer: PreparedLayer; color: string }>; summary: PlanSoils["summary"] }; bands: { stream: PreparedLayer; road: PreparedLayer; info: { stream: PlanBand; road: PlanBand } }; building: PreparedLayer; place: Array<{ lon: number; lat: number; name: string }>; @@ -191,6 +196,17 @@ export function createPlanLayers(opts: { : [{ label: "보호 · 규제 구역 없음(받은 범위 안)", color: "transparent", off: true }], ); } + if (on.soils && data.soils) { + const { kinds, summary } = data.soils; + rows.push( + summary.length + ? summary.map((soil, i) => ({ + label: `${soil.name} ${hectare(soil.area_m2)}`, + color: `var(${kinds[i].color})`, + })) + : [{ label: "산림입지도 없음(받은 범위 안)", color: "transparent", off: true }], + ); + } if (on.bands && data.bands) { const { stream, road } = data.bands.info; rows.push([ @@ -352,6 +368,15 @@ export function createPlanLayers(opts: { `보호 · 규제 구역 일부 받지 못함 — ${failed.map((kind) => kind.label).join(" · ")}`, "error", ); + } else if (key === "soils") { + const soils = await fetchPlanSoils(projectId); + data.soils = { + kinds: soils.summary.map((soil, i) => ({ + layer: prepare(pick(soils, (props) => props["code"] === soil.code)), + color: SOIL_COLORS[i % SOIL_COLORS.length], + })), + summary: soils.summary, + }; } else if (key === "bands") { const info = await fetchPlanBands(projectId); data.bands = { @@ -418,6 +443,16 @@ export function createPlanLayers(opts: { drawRaster(context, map, data.slope); }, }, + { + base: true, + draw: (context, map) => { + if (!on.soils || !data.soils) return; + for (const { layer, color } of data.soils.kinds) { + const fill = themeColor(color, "#facc15"); + fillLayer(context, layer, map.view, fill, 0.35, { color: fill, width: 1.4 }); + } + }, + }, { base: true, draw: (context, map) => { @@ -582,6 +617,13 @@ export function createPlanLayers(opts: { [`zone_${kind}_m2`, value.area_m2], ]), )), + ...(data.soils && + Object.fromEntries( + data.soils.summary.flatMap((soil) => [ + [`soil_${soil.code}`, soil.count], + [`soil_${soil.code}_m2`, soil.area_m2], + ]), + )), ...(data.bands && { band_stream_m2: data.bands.info.stream.area_m2, band_road_m2: data.bands.info.road.area_m2, diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index 431f60a3e..0256d4cb3 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -49,7 +49,7 @@ def test_d03_parts(): assert f'"{b05_only}"' not in tools, b05_only paint = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts") # 580 — 그림 나눔 assert "map.stations" in paint and "map.slopes" in paint - # 569-2 · 581-1 — 바탕 겹 단추(공용 지도 도구줄 한 줄 · 묶음 셋) · 처음 켬 셋 · 취향 기억 + # 569-2 · 581-1 · 581-2 — 바탕 겹 단추(공용 지도 도구줄 한 줄 · 묶음 셋) · 처음 켬 셋 · 취향 기억 defs = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts") assert "createMapTools({" in layers and "createIconButton({" in layers assert "createToolbar(" not in layers @@ -66,6 +66,7 @@ def test_d03_parts(): ("forestRoad", "false"), ("cadastral", "false"), ("zones", "false"), + ("soils", "false"), ("bands", "false"), ("building", "false"), ("place", "false"), diff --git a/resources/tester/test_581_2_plan_soils.py b/resources/tester/test_581_2_plan_soils.py new file mode 100644 index 000000000..32e03fecf --- /dev/null +++ b/resources/tester/test_581_2_plan_soils.py @@ -0,0 +1,97 @@ +"""D 노선분석 GIS 겹 「다음」 묶음(PLAN 581-2) — 산림입지도 잘라 넓이 · 토양형 코드 차례 +이름 없으면 글자 그대로 · 실패 = 오류(파일 안 씀) · 덮으면 다시 안 받음 · 화면 겹 정의 · 색 값. + +./venv/Scripts/python.exe -m pytest resources/tester/test_581_2_plan_soils.py -q +""" + +import json +import re +from pathlib import Path + +import pytest + +from common_util.common_util_crs import tm_to_lonlat +from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers + +EPSG = 5186 +BOX = (230000.0, 450000.0, 232000.0, 451000.0) # 2 × 1 km +ROOT = Path(__file__).resolve().parents[2] + + +def _square(x0: float, y0: float, side: float) -> dict: + ring = [(x0, y0), (x0 + side, y0), (x0 + side, y0 + side), (x0, y0 + side), (x0, y0)] + return {"type": "Polygon", "coordinates": [[list(tm_to_lonlat(x, y, EPSG)) for x, y in ring]]} + + +def _soil(gid: str, geometry: dict, cd: str, code: str, name: str | None) -> dict: + props = {"sltp_cd": cd, "toyanghyun": code, **({"name": name} if name else {})} + return {"id": gid, "geometry": geometry, "properties": props} + + +def test_clip_soils_area_order_names() -> None: + found = [ + _soil("a", _square(230100.0, 450100.0, 100.0), "03", "B₃", "갈색적윤산림토양"), + _soil("b", _square(231800.0, 450800.0, 400.0), "01", "B₁", "갈색건조산림토양"), # 귀퉁이 + _soil("b", _square(231800.0, 450800.0, 400.0), "01", "B₁", "갈색건조산림토양"), # 겹침 + _soil("c", _square(230500.0, 450500.0, 100.0), "03", "B₃", "갈색적윤산림토양"), + _soil("d", _square(230800.0, 450100.0, 50.0), "99", "Xx", None), # 모르는 값 + _soil("e", _square(240000.0, 460000.0, 100.0), "02", "B₂", "갈색약건산림토양"), # 밖 + ] + features, summary = layers.clip_soils(found, BOX, EPSG) + assert [row["code"] for row in summary] == ["B₁", "B₃", "Xx"] + assert summary[0]["area_m2"] == pytest.approx(200 * 200, rel=0.01) + assert summary[1] == { + "code": "B₃", + "name": "갈색적윤산림토양", + "count": 2, + "area_m2": pytest.approx(2 * 100 * 100, rel=0.01), + } + assert summary[2]["name"] == "Xx" # 이름 없으면 글자 그대로 + assert len(features) == 4 + assert {f["properties"]["code"] for f in features} == {"B₁", "B₃", "Xx"} + + +class FakeWfs: + def __init__(self, fail: bool = False) -> None: + self.fail, self.calls = fail, [] + + def __call__(self, typename, filename, bounds, output_dir) -> None: + self.calls.append(typename) + if self.fail: + return + feats = [_soil("a", _square(230500.0, 450200.0, 300.0), "01", "B₁", "갈색건조산림토양")] + body = {"type": "FeatureCollection", "features": feats} + (Path(output_dir) / filename).write_text(json.dumps(body), "utf-8") + + +def test_soils_fetch_once(tmp_path, monkeypatch) -> None: + fake = FakeWfs() + monkeypatch.setattr(layers, "request_vworld_wfs", fake) + got = layers.ensure_soils(tmp_path, BOX, EPSG) + assert got["fetched"] and fake.calls == [layers.SOIL_TYPENAME] + saved = json.loads((tmp_path / layers.SOIL_FILE).read_text("utf-8")) + assert saved["summary"][0]["count"] == 1 and len(saved["features"]) == 1 + inner = (230500.0, 450100.0, 231000.0, 450900.0) + assert not layers.ensure_soils(tmp_path, inner, EPSG)["fetched"] # 덮으면 다시 안 받음 + assert len(fake.calls) == 1 + assert layers.ensure_soils(tmp_path, (229000.0, *BOX[1:]), EPSG)["fetched"] # 넓어지면 다시 + + +def test_soils_fail_raises(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(layers, "request_vworld_wfs", FakeWfs(fail=True)) + with pytest.raises(RuntimeError): + layers.ensure_soils(tmp_path, BOX, EPSG) + assert not (tmp_path / layers.SOIL_FILE).exists() + + +def test_screen_def_and_colors() -> None: + defs = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_LayerDefs.ts").read_text("utf-8") + entry = re.search(r'key: "soils",\s*icon: "(\w+)",[^}]*on: (\w+)', defs) + assert entry and entry.group(2) == "false" # 처음 값 끔 + icons = (ROOT / "ui_template" / "ui_template_icons.ts").read_text("utf-8") + assert re.search(rf"^\s+{entry.group(1)}:", icons, re.M) + theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8") + assert all(f"--draw-map-soil-{n}:" in theme for n in range(1, 7)) + page = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_Layers.ts").read_text("utf-8") + assert "if (!on.soils || !data.soils) return;" in page + assert page.count("base: true,") == page.count("draw: (context, map) =>") # 겹 모두 바탕(582) diff --git a/ui_template/ui_template_icons.ts b/ui_template/ui_template_icons.ts index 169b30b56..bd09b386c 100644 --- a/ui_template/ui_template_icons.ts +++ b/ui_template/ui_template_icons.ts @@ -142,6 +142,8 @@ const PATHS = { shield: "M12 3a12 12 0 0 0 8.5 3a12 12 0 0 1 -8.5 15a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3", borderOuter: "M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z|M12 8l0 .01|M8 12l0 .01|M12 12l0 .01|M16 12l0 .01|M12 16l0 .01", + // 581-2 — 지도 겹(산림입지도) + leaf: "M5 21c.5 -4.5 2.5 -8 7 -10|M9 18c6.218 0 10.5 -3.288 11 -12v-2h-4.014c-9 0 -11.986 4 -12 9c0 1 0 3 2 5h3z", } 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 da5c48b76..64ae0a506 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -569,6 +569,12 @@ 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-2: #f5c08a; + --draw-map-soil-3: #d9895a; + --draw-map-soil-4: #bef264; + --draw-map-soil-5: #86efac; + --draw-map-soil-6: #e879f9; } :root[data-theme="dark"] {