feat(D03 겹): 노선분석 지도 산림입지도 겹 — 브이월드 WFS 토양형 · 묶음 셋째 · 처음 끔 (PLAN 581-2 · 요구 440-8)

- 서버: lt_c_fsdifrsts 받은 범위로 잘라 토양형 코드 · 이름 · 넓이 · 토양형별 합 · 덮으면 다시 안 받음 · 실패 = 502
- 화면: 단추 하나(잎 아이콘 · 부품 상자에 더함) · 토양형 색 --draw-map-soil-1~6 · 범례 = 토양형 이름 · ha · 바탕 캔버스
- 산사태위험지도(생활안전지도 = 공공누리 4유형 · 데이터 준비 중) · 단층 · 지질구조선(이용 조건 문서 없음)은 넣지 않음(브레인 결정 A)
- test_553_4: 겹 목록에 soils 더함(새 겹 · 기대 목록 갱신)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 15:15:10 +09:00
co-authored by Claude Opus 5.5
parent 63a9f16d32
commit f29fb01dd5
9 changed files with 272 additions and 16 deletions
+9 -1
View File
@@ -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<PlanZones> =>
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<PlanSoils> =>
request(`${base(projectId)}/layers/soils`);
/** 계류 100 m · 도로 300 m 띠(581-1) — 받은 범위 안 넓이 · 비율. */
export interface PlanBand {
dist_m: number;
+88 -9
View File
@@ -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"
+13 -1
View File
@@ -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 띠(넓이 · 받은 범위 비율) — 두 도엽 겹 지문 같으면 저장본."""
@@ -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<string, string> = {
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) 면 셋. */
+45 -3
View File
@@ -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<string, PreparedLayer>; 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,