Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_LandslideZone.py
eomsangdonandClaude Opus 5.5 5db9cb49f1 feat(D03 겹): 노선분석 지도 올린 임도망도 · 산사태취약지역 겹 — 시군구 SHP 올리기 · 시도 표 점 + 지번 필지 테두리 (PLAN 584-3 · 요구 443)
- 임도망도: 지도 겹 줄에 올리기 단추(zip 하나 · shp · shx · dbf · prj 묶음 · 100 MB · 넘으면 413) → 서버가 읽어 받은 범위로 자름(prj 없으면 EPSG:5179 · cpg 없으면 cp949) · 원본 = 프로젝트 D03_PlanAnalysis/임도망도.zip · 자른 선 = 파생 저장본 · 못 읽거나 범위 안 선 0 = 400 · 옛 것 그대로
- 범례: 「임도 짐작」(주황 점선) · 「올린 임도망도」(진한 주황 실선) 구분 · 짐작 겹 그대로
- 산사태취약지역: data.go.kr 시도 표 넷(경북 · 충남 · 광주 · 경남)을 resources/data_landslide_zones 에 한 벌(머리 = 이름 · 받은 날 · 기준일 · 출처) · 시도마다 다른 열 이름은 별칭으로 · 위경도 = 받은 범위 안 점 · 지번(시군구 · 읍면동 · 리 · 산 · 본번 · 부번) = 지적 필지 테두리만 · 사업지 시도에 표 없으면 범례 「이 시도 자료 없음」 · 지적 못 받으면 점만
- 두 겹 처음 끔 · 바탕 캔버스 · 색 --draw-map-forest-road-file · landslide-zone · 아이콘 alertOctagon · 고르기 창 pickFiles(부품 상자 ui_template_drop)
- 범례 · 겹별 수 · 받은 자료 꼴을 D03_PlanAnalysis_UI_LayerLegend.ts 로 옮김(700줄 한도)
- test_553_4: 겹 목록에 forestRoadFile · landslideZones 더함(새 겹 · 기대 목록 갱신)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:27:45 +09:00

265 lines
9.6 KiB
Python

"""D 노선분석 지도 산사태취약지역 겹(PLAN 584-3 · 요구 443).
- 출처 = 시도 표(data.go.kr · 이용허락범위 제한 없음) 한 벌 = `resources/data_landslide_zones/
<시도코드>_<시도이름>.csv`(UTF-8 · 머리 `#` 줄 = 이름 · 받은 날 · 기준일 · 출처) ·
해마다 파일만 바꿈
- 시도마다 열 이름이 다름 → `COLUMNS` 별칭으로 읽음 · 위경도(도 · 분 · 초) 없는 시도(경남) = 지번만
- 위경도 있는 줄 = 받은 범위 안 점 · 지번(시군구 · 읍면동 · 리 · 산 · 본번 · 부번)이 지적 필지와
맞으면 필지 테두리(지정 넓이 ≪ 필지 넓이 — 칠하지 않음)
- 사업지 시도 = 지적 필지 `sido_cd` · 표 없는 시도 = `missing`
- 저장 = 파생 저장본(열쇠 = 표 지문 · 지적 지문 · 범위)
계산 자리 = ③ 서버 단독.
"""
from __future__ import annotations
import csv
import json
import re
from functools import lru_cache
from pathlib import Path
from typing import Any
from common_util.common_util_derived_store import (
derived_key,
file_stamp,
read_derived,
write_derived,
)
Box = tuple[float, float, float, float]
DATA_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_landslide_zones"
ZONE_KIND = "plan_landslide_zone"
ZONE_VERSION = 1
# 열쇠 → 시도 표 열 이름 별칭(경북 · 충남 · 광주 · 경남)
COLUMNS: dict[str, tuple[str, ...]] = {
"sgg": ("시군구", "조사지(시군구)"),
"emd": ("읍면동", "조사지(동)"),
"ri": ("리",),
"jibun": ("지번",),
"lat_d": ("(위도)도", "위도(도)"),
"lat_m": ("(위도)분", "위도(분)"),
"lat_s": ("(위도)초", "위도(초)"),
"lon_d": ("(경도)도", "경도(도)"),
"lon_m": ("(경도)분", "경도(분)"),
"lon_s": ("(경도)초", "경도(초)"),
"kind": ("취약지역유형",),
"area": ("지정면적(제곱미터)",),
}
JIBUN = re.compile(r"^\s*(산)?\s*(\d+)(?:\s*-\s*(\d+))?")
ParcelKey = tuple[str, str, str, bool, int, int]
def jibun_key(text: str) -> tuple[bool, int, int] | None:
"""「산42임」 · 「산77-1번지」 · 「130-61임, 파도리 산65」 → (산, 본번, 부번) · 첫 지번만 ·
못 읽으면 None."""
found = JIBUN.match(str(text or ""))
if not found:
return None
return bool(found.group(1)), int(found.group(2)), int(found.group(3) or 0)
def _name(text: Any) -> str:
return re.sub(r"\s+", "", str(text or ""))
def _number(text: Any) -> float | None:
try:
return float(str(text).strip())
except (TypeError, ValueError):
return None
def _dms(row: dict[str, str], prefix: str) -> float | None:
parts = [_number(row.get(f"{prefix}_{p}")) for p in "dms"]
if parts[0] is None:
return None
d, m, s = (v or 0.0 for v in parts)
return d + m / 60 + s / 3600
def read_table(path: Path) -> tuple[dict[str, str], list[dict[str, Any]]]:
"""시도 표 → (머리 {이름 · 받은 날 · 기준일 · 출처}, 줄 [{sgg, emd, ri, jibun, key, lon, lat,
kind, area_m2}]) — 위경도 없거나 못 읽으면 lon · lat = None."""
return _read_table(str(path), file_stamp(path))
@lru_cache(maxsize=8)
def _read_table(path: str, _stamp: str | None) -> tuple[dict[str, str], list[dict[str, Any]]]:
head: dict[str, str] = {}
body: list[str] = []
for line in Path(path).read_text(encoding="utf-8-sig").splitlines():
if line.startswith("#"):
name, _, value = line[1:].partition(":")
head[name.strip()] = value.strip()
elif line.strip():
body.append(line)
reader = csv.DictReader(body)
fields = reader.fieldnames or []
alias = {
key: next((f for f in fields if f.strip() in names), None) for key, names in COLUMNS.items()
}
rows: list[dict[str, Any]] = []
for raw in reader:
row = {key: (raw.get(col) or "").strip() if col else "" for key, col in alias.items()}
lat, lon = _dms(row, "lat"), _dms(row, "lon")
rows.append(
{
"sgg": row["sgg"],
"emd": row["emd"],
"ri": row["ri"],
"jibun": row["jibun"],
"key": jibun_key(row["jibun"]),
"lon": lon if lat is not None else None,
"lat": lat if lon is not None else None,
"kind": row["kind"] or None,
"area_m2": _number(row["area"]),
}
)
return head, rows
def table_of(code: str) -> Path | None:
"""시도 코드(두 자리) → 표 파일 · 없으면 None."""
return next(iter(sorted(DATA_DIR.glob(f"{code}_*.csv"))), None)
def parcel_key(props: dict[str, Any]) -> ParcelKey | None:
"""지적 필지 속성 → 맞춤 열쇠(임야대장 `gbn_cd` 2 = 산)."""
try:
main, sub = int(props.get("mnnm")), int(props.get("slno") or 0)
except (TypeError, ValueError):
return None
san = str(props.get("gbn_cd")) == "2"
return (
_name(props.get("sgg_nm")),
_name(props.get("emd_nm")),
_name(props.get("ri_nm")),
san,
main,
sub,
)
def row_key(row: dict[str, Any]) -> ParcelKey | None:
if row["key"] is None:
return None
return (_name(row["sgg"]), _name(row["emd"]), _name(row["ri"]), *row["key"])
def match_zones(
parcels: list[dict[str, Any]], tables: dict[str, Path | None], box: Box, epsg: int
) -> dict[str, Any]:
"""지적 필지 · 시도 표 → 받은 범위 안 점 · 지번 맞는 필지 테두리 · 시도별 표 · 없는 시도."""
from pyproj import Transformer
from shapely.geometry import Point
from shapely.geometry import box as rect
to_m = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True).transform
frame = rect(*box)
index: dict[ParcelKey, list[dict[str, Any]]] = {}
for parcel in parcels:
key = parcel_key(parcel.get("properties") or {})
if key is not None:
index.setdefault(key, []).append(parcel)
names = {
str((p.get("properties") or {}).get("sido_cd")): str(
(p.get("properties") or {}).get("sido_nm")
)
for p in parcels
}
points: list[dict[str, Any]] = []
hits: dict[int, dict[str, Any]] = {}
sidos: list[dict[str, Any]] = []
missing: list[str] = []
for code, path in sorted(tables.items()):
if path is None:
missing.append(names.get(code) or code)
continue
head, rows = read_table(path)
located = 0
for row in rows:
place = f"{row['sgg']} {row['emd']} {row['ri']} {row['jibun']}".replace(
" ", " "
).strip()
if row["lon"] is not None:
located += 1
if frame.contains(Point(to_m(row["lon"], row["lat"]))):
points.append(
{
"lon": round(row["lon"], 7),
"lat": round(row["lat"], 7),
"kind": row["kind"],
"area_m2": row["area_m2"],
"place": place,
}
)
for parcel in index.get(row_key(row) or ("",), []):
hit = hits.setdefault(
id(parcel), {"parcel": parcel, "count": 0, "kinds": set(), "area_m2": 0.0}
)
hit["count"] += 1
hit["area_m2"] += row["area_m2"] or 0.0
if row["kind"]:
hit["kinds"].add(row["kind"])
sidos.append(
{
"code": code,
"name": names.get(code) or path.stem.split("_", 1)[-1],
"rows": len(rows),
"located": located,
"title": head.get("이름"),
"basis": head.get("기준일"),
"source": head.get("출처"),
}
)
features = [
{
"type": "Feature",
"geometry": hit["parcel"]["geometry"],
"properties": {
"jibun": (hit["parcel"].get("properties") or {}).get("jibun"),
"count": hit["count"],
"kinds": sorted(hit["kinds"]),
"area_m2": round(hit["area_m2"], 1),
},
}
for hit in hits.values()
]
return {
"points": points,
"parcels": {"type": "FeatureCollection", "features": features},
"sidos": sidos,
"missing": missing,
}
def zone_layer(project_root: Path, cadastral: Path | None, box: Box, epsg: int) -> dict[str, Any]:
"""산사태취약지역 — 지적 필지 시도의 표로 맞춤 · 지적이 없으면(못 받음) 위경도 있는 표
모두 점만."""
parcels = []
if cadastral is not None:
parcels = (json.loads(cadastral.read_text(encoding="utf-8")) or {}).get("features") or []
codes = sorted({str((p.get("properties") or {}).get("sido_cd")) for p in parcels} - {"None"})
if not codes:
codes = sorted(path.name.split("_", 1)[0] for path in DATA_DIR.glob("*.csv"))
tables = {code: table_of(code) for code in codes}
key = derived_key(
ZONE_KIND,
ZONE_VERSION,
{code: file_stamp(path) for code, path in tables.items()},
file_stamp(cadastral),
int(epsg),
[round(v, 1) for v in box],
)
saved = read_derived(project_root, ZONE_KIND, key)
if saved is not None:
return saved["value"]
value = match_zones(parcels, tables, box, epsg)
value["cadastral"] = cadastral is not None
write_derived(project_root, ZONE_KIND, key, value)
return value