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>
This commit is contained in:
@@ -203,3 +203,56 @@ export const fetchPlanZeroOrder = (projectId: string): Promise<PlanZeroOrder> =>
|
||||
|
||||
export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> =>
|
||||
request(`${base(projectId)}/layers/bands`);
|
||||
|
||||
/** 올린 임도망도(584-3) — 받은 범위로 자른 선 + 가닥 · 길이 m · 원본 선 수 · 좌표계 · 올린 파일. */
|
||||
export interface PlanForestRoads {
|
||||
count: number;
|
||||
length_m: number;
|
||||
total: number;
|
||||
crs: string[];
|
||||
source: { files: string[]; bytes: number; uploaded_at: string } | null;
|
||||
collection: GeoJsonCollection;
|
||||
}
|
||||
|
||||
/** 올린 적 없으면 오류(404 글 그대로). */
|
||||
export const fetchPlanForestRoads = (projectId: string): Promise<PlanForestRoads> =>
|
||||
request(`${base(projectId)}/layers/forest-roads`);
|
||||
|
||||
/** 임도망도 SHP 올리기(zip 하나 · shp · shx · dbf · prj 묶음) — 못 읽으면 서버 글 그대로 오류. */
|
||||
export async function uploadPlanForestRoads(
|
||||
projectId: string,
|
||||
files: File[],
|
||||
): Promise<PlanForestRoads> {
|
||||
const form = new FormData();
|
||||
for (const file of files) form.append("files", file);
|
||||
const response = await fetch(`${API_BASE_URL}${base(projectId)}/layers/forest-roads`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: form,
|
||||
});
|
||||
const body = (await response.json().catch(() => ({}))) as PlanForestRoads & {
|
||||
detail?: unknown;
|
||||
};
|
||||
if (!response.ok)
|
||||
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
/** 산사태취약지역(584-3) — 받은 범위 안 점 · 지번 맞는 필지 테두리 · 시도별 표 · 표 없는 시도. */
|
||||
export interface PlanLandslideZones {
|
||||
points: Array<{
|
||||
lon: number;
|
||||
lat: number;
|
||||
kind: string | null;
|
||||
area_m2: number | null;
|
||||
place: string;
|
||||
}>;
|
||||
parcels: GeoJsonCollection;
|
||||
sidos: Array<{ code: string; name: string; rows: number; located: number; basis?: string }>;
|
||||
missing: string[];
|
||||
/** 지적도를 받았나(못 받으면 점만). */
|
||||
cadastral: boolean;
|
||||
}
|
||||
|
||||
export const fetchPlanLandslideZones = (projectId: string): Promise<PlanLandslideZones> =>
|
||||
request(`${base(projectId)}/layers/landslide-zones`);
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""D 노선분석 지도 올린 임도망도 겹(PLAN 584-3 · 요구 443).
|
||||
|
||||
- 출처 = 사용자가 받은 산림청 임도망도 SHP(FGIS 무료신청 · 산림빅데이터거래소 · 시군구 단위 · 선)
|
||||
- 올림 = zip 하나 또는 shp · shx · dbf(· prj · cpg) 묶음 · 합계 `MAX_BYTES` · 읽어 보고 받은 범위 안
|
||||
선이 있어야 받음(아니면 옛 것 그대로 · 까닭 = `ValueError`)
|
||||
- 원본 = 프로젝트 `D03_PlanAnalysis/임도망도.zip`(낱 파일은 zip 으로 묶음) +
|
||||
`임도망도.json`(파일 이름 · 올린 때)
|
||||
- 좌표계 = prj · 없으면 EPSG:5179 · dbf 글자 = cpg · 없으면 cp949 · 받은 범위로 잘라 가닥 · 길이 →
|
||||
파생 저장본(열쇠 = 원본 지문 · 범위)
|
||||
|
||||
계산 자리 = ③ 서버 단독.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_derived_store import (
|
||||
derived_key,
|
||||
file_stamp,
|
||||
read_derived,
|
||||
write_derived,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
Box = tuple[float, float, float, float]
|
||||
|
||||
FOLDER = "D03_PlanAnalysis"
|
||||
SOURCE_ZIP = "임도망도.zip"
|
||||
SOURCE_META = "임도망도.json"
|
||||
DEFAULT_EPSG = 5179
|
||||
DBF_ENCODING = "cp949"
|
||||
MAX_BYTES = 100 * 1024 * 1024
|
||||
SHP_PARTS = (".shp", ".shx", ".dbf", ".prj", ".cpg")
|
||||
ROAD_KIND = "plan_forest_road"
|
||||
ROAD_VERSION = 1
|
||||
|
||||
|
||||
def source_dir(project_root: Path) -> Path:
|
||||
return Path(project_root) / FOLDER
|
||||
|
||||
|
||||
def pack(files: list[tuple[str, bytes]]) -> bytes:
|
||||
"""올린 파일 → zip 바이트 — zip 하나는 그대로 · 낱 파일은 shp · shx · dbf 가 다 있어야 묶음."""
|
||||
if len(files) == 1 and files[0][0].lower().endswith(".zip"):
|
||||
return files[0][1]
|
||||
parts = [(name, data) for name, data in files if Path(name).suffix.lower() in SHP_PARTS]
|
||||
have = {Path(name).suffix.lower() for name, _ in parts}
|
||||
lack = [ext for ext in (".shp", ".shx", ".dbf") if ext not in have]
|
||||
if lack:
|
||||
raise ValueError(
|
||||
f"SHP 묶음에 {' · '.join(lack)} 이(가) 없습니다 — "
|
||||
"zip 하나 또는 shp · shx · dbf · prj 를 함께 고르세요."
|
||||
)
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, data in parts:
|
||||
zf.writestr(Path(name).name, data)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def read_shapes(zip_path: Path) -> list[Any]:
|
||||
"""zip 안 SHP 마다 GeoDataFrame(좌표계 = prj · 없으면 5179) — 못 읽으면 `ValueError`."""
|
||||
import geopandas as gpd
|
||||
|
||||
try:
|
||||
zf = zipfile.ZipFile(zip_path)
|
||||
except zipfile.BadZipFile as error:
|
||||
raise ValueError("zip 파일을 열지 못했습니다.") from error
|
||||
frames = []
|
||||
with zf, tempfile.TemporaryDirectory() as folder:
|
||||
members = [n for n in zf.namelist() if Path(n).suffix.lower() in SHP_PARTS]
|
||||
if not any(n.lower().endswith(".shp") for n in members):
|
||||
raise ValueError("zip 안에 .shp 파일이 없습니다.")
|
||||
for n in members: # 폴더 없이 이름만 = 바깥으로 안 풂
|
||||
(Path(folder) / Path(n).name).write_bytes(zf.read(n))
|
||||
for shp in sorted(Path(folder).glob("*.shp")):
|
||||
options = {} if shp.with_suffix(".cpg").is_file() else {"encoding": DBF_ENCODING}
|
||||
try:
|
||||
frame = gpd.read_file(shp, **options)
|
||||
except Exception as error: # noqa: BLE001 — 읽기 엔진 오류 종류가 많음
|
||||
raise ValueError(f"{shp.name} 을(를) 읽지 못했습니다 — {error}") from error
|
||||
if frame.crs is None:
|
||||
frame = frame.set_crs(DEFAULT_EPSG)
|
||||
frames.append(frame)
|
||||
return frames
|
||||
|
||||
|
||||
def _plain(value: Any) -> Any:
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
return None
|
||||
return value if isinstance(value, (str, int, float, bool)) or value is None else str(value)
|
||||
|
||||
|
||||
def clip_roads(zip_path: Path, box: Box, epsg: int) -> dict[str, Any]:
|
||||
"""SHP 선 → 받은 범위로 잘라 경위도 선(원 속성 + `length_m`) · 가닥 · 길이 · 원본 선 수 ·
|
||||
좌표계."""
|
||||
from pyproj import Transformer
|
||||
from shapely.geometry import box as rect
|
||||
from shapely.geometry import mapping
|
||||
from shapely.ops import transform
|
||||
|
||||
frame = rect(*box)
|
||||
to_ll = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True).transform
|
||||
features: list[dict[str, Any]] = []
|
||||
total, length, crs = 0, 0.0, []
|
||||
for shapes in read_shapes(zip_path):
|
||||
crs.append(shapes.crs.to_string())
|
||||
shapes = shapes[shapes.geometry.notna()]
|
||||
shapes = shapes[shapes.geometry.geom_type.isin(("LineString", "MultiLineString"))]
|
||||
total += len(shapes)
|
||||
metric = shapes.to_crs(epsg)
|
||||
for (_, row), geometry in zip(
|
||||
shapes.drop(columns=shapes.geometry.name).iterrows(), metric.geometry
|
||||
):
|
||||
part = geometry.intersection(frame)
|
||||
if part.is_empty or part.length <= 0:
|
||||
continue
|
||||
length += part.length
|
||||
props = {str(k): _plain(v) for k, v in row.items()}
|
||||
props["length_m"] = round(part.length, 1)
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": mapping(transform(to_ll, part)),
|
||||
"properties": props,
|
||||
}
|
||||
)
|
||||
if not total:
|
||||
raise ValueError("SHP 에 선 지물이 없습니다 — 임도망도(선) 파일인지 보세요.")
|
||||
return {
|
||||
"count": len(features),
|
||||
"length_m": round(length, 1),
|
||||
"total": total,
|
||||
"crs": sorted(set(crs)),
|
||||
"collection": {"type": "FeatureCollection", "features": features},
|
||||
}
|
||||
|
||||
|
||||
def road_layer(project_root: Path, box: Box, epsg: int) -> dict[str, Any]:
|
||||
"""올린 임도망도 — 원본 지문 · 범위 같으면 저장본 · 올린 적 없으면 `FileNotFoundError`."""
|
||||
path = source_dir(project_root) / SOURCE_ZIP
|
||||
stamp = file_stamp(path)
|
||||
if stamp is None:
|
||||
raise FileNotFoundError(
|
||||
"올린 임도망도가 없습니다 — 옆 올리기 단추로 시군구 SHP 를 올리세요."
|
||||
)
|
||||
key = derived_key(ROAD_KIND, ROAD_VERSION, stamp, int(epsg), [round(v, 1) for v in box])
|
||||
saved = read_derived(project_root, ROAD_KIND, key)
|
||||
if saved is not None:
|
||||
return saved["value"]
|
||||
value = clip_roads(path, box, epsg)
|
||||
try:
|
||||
value["source"] = json.loads((source_dir(project_root) / SOURCE_META).read_text("utf-8"))
|
||||
except (OSError, ValueError):
|
||||
value["source"] = None
|
||||
write_derived(project_root, ROAD_KIND, key, value)
|
||||
return value
|
||||
|
||||
|
||||
def save_roads(
|
||||
project_root: Path, files: list[tuple[str, bytes]], box: Box, epsg: int
|
||||
) -> dict[str, Any]:
|
||||
"""올림 — 묶어 읽어 보고 받은 범위 안 선이 있으면 원본을 바꿈 · 아니면 `ValueError`
|
||||
(옛 것 그대로)."""
|
||||
data = pack(files)
|
||||
folder = source_dir(project_root)
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=folder, suffix=".zip.new", delete=False) as temporary:
|
||||
temporary.write(data)
|
||||
trial = Path(temporary.name)
|
||||
try:
|
||||
got = clip_roads(trial, box, epsg)
|
||||
if not got["count"]:
|
||||
raise ValueError(
|
||||
f"받은 범위 안에 임도가 없습니다(파일 선 {got['total']}가닥) — "
|
||||
"사업지 시군구 파일인지 보세요."
|
||||
)
|
||||
os.replace(trial, folder / SOURCE_ZIP)
|
||||
finally:
|
||||
trial.unlink(missing_ok=True)
|
||||
atomic_write_json(
|
||||
folder / SOURCE_META,
|
||||
{
|
||||
"files": [Path(name).name for name, _ in files],
|
||||
"bytes": len(data),
|
||||
"uploaded_at": datetime.now().isoformat(timespec="seconds"),
|
||||
},
|
||||
)
|
||||
return road_layer(project_root, box, epsg)
|
||||
@@ -0,0 +1,264 @@
|
||||
"""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
|
||||
@@ -4,8 +4,9 @@
|
||||
- `POST …/plan/terrain` — 사업 전체 지형 한 벌 받기(뒤에서 · 누를 때만) · 끝나면 `surface_models` 행
|
||||
- `GET …/plan/progress` — 일의 진행률
|
||||
- `GET …/plan/layers/sheet?key=` · `…/cadastral` · `…/raster?kind=slope|hillshade|landslide` ·
|
||||
`…/zones` · `…/soils` · `…/bands` · `…/lines?kind=` · `…/zero-order` — 지도 겹
|
||||
(PLAN 569-1 · 581-1 · 581-2 · 584-1)
|
||||
`…/zones` · `…/soils` · `…/bands` · `…/lines?kind=` · `…/zero-order` ·
|
||||
`…/forest-roads`(GET · POST 올리기) · `…/landslide-zones` — 지도 겹
|
||||
(PLAN 569-1 · 581-1 · 581-2 · 584-1 · 584-3)
|
||||
|
||||
범위 = 전체 시점 · 종점(D02 정본 `load_base`) + 그은 점(`extra`) 상자 + 여유.
|
||||
노선 정본(손 그은 선)은
|
||||
@@ -21,7 +22,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi import APIRouter, File, HTTPException, Request, Response, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
@@ -29,7 +30,9 @@ from common_util.common_util_http_cache import cached_file_response
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
from D02_PlanBase import D02_PlanBase_Router as base
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_ForestRoad as forest_road
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Landslide as landslide
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_LandslideZone as landslide_zone
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Terrain as terrain
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_ZeroOrder as zero_order
|
||||
@@ -291,3 +294,58 @@ async def plan_layer_zero_order(
|
||||
except FileNotFoundError as error:
|
||||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||||
return {"status": "success", **value}
|
||||
|
||||
|
||||
@router.get("/{project_id}/plan/layers/forest-roads", response_model=None)
|
||||
async def plan_layer_forest_roads(project_id: UUID) -> dict[str, Any]:
|
||||
"""올린 임도망도(받은 범위로 자른 선 · 가닥 · 길이) — 올린 적 없으면 404."""
|
||||
root, epsg, box = await _layer_root(project_id)
|
||||
try:
|
||||
value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg)
|
||||
except FileNotFoundError as error:
|
||||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||||
return {"status": "success", **value}
|
||||
|
||||
|
||||
@router.post("/{project_id}/plan/layers/forest-roads", response_model=None)
|
||||
async def plan_layer_forest_roads_upload(
|
||||
project_id: UUID, files: list[UploadFile] = File(...)
|
||||
) -> dict[str, Any]:
|
||||
"""임도망도 SHP 올리기(zip 하나 · shp · shx · dbf · prj 묶음 · 합계 100 MB) — 못 읽거나
|
||||
받은 범위 안 선이 없으면 400(옛 것 그대로) · 넘치면 413."""
|
||||
root, epsg, box = await _layer_root(project_id)
|
||||
got: list[tuple[str, bytes]] = []
|
||||
total = 0
|
||||
for upload in files:
|
||||
chunks = []
|
||||
while chunk := await upload.read(1024 * 1024):
|
||||
total += len(chunk)
|
||||
if total > forest_road.MAX_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"파일이 너무 큽니다 — 모두 {forest_road.MAX_BYTES // 2**20} MB 까지",
|
||||
)
|
||||
chunks.append(chunk)
|
||||
got.append((upload.filename or "", b"".join(chunks)))
|
||||
if not total:
|
||||
raise HTTPException(status_code=400, detail="빈 파일입니다.")
|
||||
try:
|
||||
value = await asyncio.to_thread(forest_road.save_roads, root, got, box, epsg)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
return {"status": "success", **value}
|
||||
|
||||
|
||||
@router.get("/{project_id}/plan/layers/landslide-zones", response_model=None)
|
||||
async def plan_layer_landslide_zones(project_id: UUID) -> dict[str, Any]:
|
||||
"""산사태취약지역(시도 표 · 점 + 지번 맞는 필지 테두리 · 표 없는 시도) — 지적도 못 받으면
|
||||
점만."""
|
||||
root, epsg, box = await _layer_root(project_id)
|
||||
processed = root / terrain.STAGE / "processed"
|
||||
try:
|
||||
got = await asyncio.to_thread(layers.ensure_cadastral, processed, box, epsg)
|
||||
cadastral = got["path"]
|
||||
except RuntimeError:
|
||||
cadastral = None
|
||||
value = await asyncio.to_thread(landslide_zone.zone_layer, root, cadastral, box, epsg)
|
||||
return {"status": "success", **value}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_LayerDefs.ts
|
||||
* D03 바탕 겹 정의(PLAN 569-2 · 581-1 · 581-2 · 584-1) — 겹 단추 묶음 · 처음 값 · 보호구역 · 토양형 색 ·
|
||||
* D03 바탕 겹 정의(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3) — 겹 단추 묶음 · 처음 값 · 보호구역 · 토양형 색 ·
|
||||
* 산사태위험지도 범례 · 선 겹(단층 · 지질구조선 · 0차곡) 꼴 · 켬끔 취향 읽기 · 면 겹 채우기 · 선 긋기 · 그림 겹 놓기.
|
||||
* 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기 차례 · 범례).
|
||||
* ========================================================================== */
|
||||
@@ -26,10 +26,12 @@ export type PlanLayerKey =
|
||||
| "water"
|
||||
| "road"
|
||||
| "forestRoad"
|
||||
| "forestRoadFile"
|
||||
| "cadastral"
|
||||
| "zones"
|
||||
| "soils"
|
||||
| "landslide"
|
||||
| "landslideZones"
|
||||
| "faults"
|
||||
| "lineaments"
|
||||
| "zeroOrder"
|
||||
@@ -62,6 +64,12 @@ export const GROUPS: readonly (readonly LayerDef[])[] = [
|
||||
title: "기존 임도 짐작(도엽 도로 소로 · 미분류 = 주황 점선 · 정식 임도망도 아님)",
|
||||
on: false,
|
||||
},
|
||||
{
|
||||
key: "forestRoadFile",
|
||||
icon: "map",
|
||||
title: "올린 임도망도(산림청 시군구 SHP = 진한 주황 실선 · 옆 단추로 올림)",
|
||||
on: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
{ key: "cadastral", icon: "borderAll", title: "지적도(참고용 · 법적 효력 없음)", on: false },
|
||||
@@ -83,6 +91,12 @@ export const GROUPS: readonly (readonly LayerDef[])[] = [
|
||||
title: "산사태위험지도(1~5 등급 · 행안부 생활안전지도 · 참고용)",
|
||||
on: false,
|
||||
},
|
||||
{
|
||||
key: "landslideZones",
|
||||
icon: "alertOctagon",
|
||||
title: "산사태취약지역(시도 지정 현황 · 점 + 지번 필지 테두리 · 참고용)",
|
||||
on: false,
|
||||
},
|
||||
{ key: "faults", icon: "bolt", title: "단층(브이월드 지질 · 참고용)", on: false },
|
||||
{
|
||||
key: "lineaments",
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_LayerLegend.ts
|
||||
* D03 바탕 겹 받은 자료 꼴 · 범례 줄 · 겹별 수(PLAN 569-2 · 581 · 584-1 · 584-3) — 켠 겹의 받은 범위 안 수 · 넓이.
|
||||
* 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { LegendItem } from "@ui/ui_template_flow_legend";
|
||||
import type { PreparedLayer } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import type {
|
||||
PlanBand,
|
||||
PlanForestRoads,
|
||||
PlanLandslideZones,
|
||||
PlanRaster,
|
||||
PlanSoils,
|
||||
PlanZones,
|
||||
} from "./D03_PlanAnalysis_Api";
|
||||
import {
|
||||
LANDSLIDE_LEGEND,
|
||||
LINE_KEYS,
|
||||
LINE_LAYERS,
|
||||
ZONE_COLORS,
|
||||
hectare,
|
||||
type LineLayerKey,
|
||||
type PlanLayerKey,
|
||||
} from "./D03_PlanAnalysis_UI_LayerDefs";
|
||||
|
||||
/** 선 겹 — 그릴 선 · 받은 범위 안 가닥 · 길이 m. */
|
||||
export interface PreparedLines {
|
||||
layer: PreparedLayer;
|
||||
count: number;
|
||||
length_m: number;
|
||||
}
|
||||
export type Prepared = Record<LineLayerKey, PreparedLines> & {
|
||||
contour: PreparedLayer;
|
||||
/** 세류 · 하천(소하천 · 지방하천 · 연결선). */
|
||||
stream: [PreparedLayer, PreparedLayer];
|
||||
cadastral: PreparedLayer;
|
||||
/** 전체 · 그중 임도 짐작(소로 · 미분류) — `road` · `forestRoad` 가 함께 씀. */
|
||||
road: { all: PreparedLayer; small: PreparedLayer };
|
||||
/** 올린 임도망도(584-3). */
|
||||
forestRoadFile: { layer: PreparedLayer; info: PlanForestRoads };
|
||||
slope: { raster: PlanRaster; image: HTMLImageElement };
|
||||
hillshade: { raster: PlanRaster; image: HTMLImageElement };
|
||||
landslide: { raster: PlanRaster; image: HTMLImageElement };
|
||||
/** 산사태취약지역(584-3) — 점(경위도) · 지번 맞는 필지 테두리. */
|
||||
landslideZones: { parcels: PreparedLayer; info: PlanLandslideZones };
|
||||
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 }>;
|
||||
};
|
||||
|
||||
const km = (m: number): string => `${(m / 1000).toFixed(2)} km`;
|
||||
|
||||
/** 산사태취약지역 범례 — 점 · 필지 · 위치 없는 시도 · 표 없는 시도 · 지적 못 받음. */
|
||||
function landslideZoneRow({ info }: Prepared["landslideZones"]): LegendItem[] {
|
||||
const color = "var(--draw-map-landslide-zone)";
|
||||
const items: LegendItem[] = [
|
||||
{
|
||||
label: `산사태취약지역 점 ${info.points.length}곳`,
|
||||
color,
|
||||
kind: "dot",
|
||||
off: !info.points.length,
|
||||
},
|
||||
{
|
||||
label: `지번 맞는 필지 ${info.parcels.features?.length ?? 0}곳(테두리)`,
|
||||
color,
|
||||
kind: "dash",
|
||||
off: !info.parcels.features?.length,
|
||||
},
|
||||
];
|
||||
for (const sido of info.sidos)
|
||||
if (!sido.located) items.push({ label: `${sido.name} 위치 없음 — 필지만`, color, off: true });
|
||||
for (const name of info.missing)
|
||||
items.push({ label: `${name} — 이 시도 자료 없음`, color: "transparent", off: true });
|
||||
if (!info.cadastral)
|
||||
items.push({ label: "지적도 못 받음 — 필지 테두리 없음", color: "transparent", off: true });
|
||||
return items;
|
||||
}
|
||||
|
||||
export function legendRows(
|
||||
on: Record<PlanLayerKey, boolean>,
|
||||
data: Partial<Prepared>,
|
||||
): LegendItem[][] {
|
||||
const rows: LegendItem[][] = [];
|
||||
if (on.slope && data.slope)
|
||||
rows.push(
|
||||
data.slope.raster.bands.map((band) => ({
|
||||
label: `${band.min_deg}${band.max_deg < 90 ? `~${band.max_deg}°` : "°↑"} ${(band.ratio * 100).toFixed(1)} %`,
|
||||
color: band.color,
|
||||
})),
|
||||
);
|
||||
if (on.landslide && data.landslide) rows.push(LANDSLIDE_LEGEND);
|
||||
if (on.landslideZones && data.landslideZones) rows.push(landslideZoneRow(data.landslideZones));
|
||||
if (on.zones && data.zones) {
|
||||
const items = Object.entries(data.zones.summary)
|
||||
.filter(([, kind]) => kind.count || kind.failed)
|
||||
.map(([key, kind]) => ({
|
||||
label: kind.failed
|
||||
? `${kind.label} 받지 못함`
|
||||
: `${kind.label} ${kind.count}곳 ${hectare(kind.area_m2)}`,
|
||||
color: `var(${ZONE_COLORS[key]})`,
|
||||
off: !!kind.failed,
|
||||
}));
|
||||
rows.push(
|
||||
items.length
|
||||
? items
|
||||
: [{ 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([
|
||||
{
|
||||
label: `계류 ${stream.dist_m} m 띠 ${hectare(stream.area_m2)} (${(stream.ratio * 100).toFixed(1)} %)`,
|
||||
color: "var(--draw-map-band-stream)",
|
||||
},
|
||||
{
|
||||
label: `도로 ${road.dist_m} m 띠 ${hectare(road.area_m2)} (${(road.ratio * 100).toFixed(1)} %)`,
|
||||
color: "var(--draw-map-band-road)",
|
||||
},
|
||||
]);
|
||||
}
|
||||
const lines = LINE_KEYS.flatMap((key) => {
|
||||
const got = on[key] && data[key];
|
||||
if (!got) return [];
|
||||
const { label, color, dash } = LINE_LAYERS[key];
|
||||
return [
|
||||
{
|
||||
label: `${label} ${got.count}가닥 ${km(got.length_m)}`,
|
||||
color: `var(${color})`,
|
||||
kind: dash.length ? ("dash" as const) : ("line" as const),
|
||||
off: !got.count,
|
||||
},
|
||||
];
|
||||
});
|
||||
if (lines.length) rows.push(lines);
|
||||
const tail: LegendItem[] = [];
|
||||
if (on.water && data.water)
|
||||
tail.push({ label: `물 면 ${data.water.features.length}곳`, color: "var(--draw-map-water)" });
|
||||
if (on.building && data.building)
|
||||
tail.push({
|
||||
label: `건물 ${data.building.features.length.toLocaleString()}동`,
|
||||
color: "var(--draw-map-building)",
|
||||
});
|
||||
if (on.forestRoad && data.road)
|
||||
tail.push({
|
||||
label: `임도 짐작 ${data.road.small.features.length}가닥`,
|
||||
color: "var(--draw-map-forest-road)",
|
||||
kind: "dash",
|
||||
});
|
||||
if (on.forestRoadFile && data.forestRoadFile)
|
||||
tail.push({
|
||||
label: `올린 임도망도 ${data.forestRoadFile.info.count}가닥 ${km(data.forestRoadFile.info.length_m)}`,
|
||||
color: "var(--draw-map-forest-road-file)",
|
||||
kind: "line",
|
||||
});
|
||||
if (tail.length) rows.push(tail);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 검증 창구 — 겹별 수(지물 · 넓이 m² · 길이 m). */
|
||||
export const layerCounts = (data: Partial<Prepared>): Record<string, number> => ({
|
||||
...(data.building && { building: data.building.features.length }),
|
||||
...(data.water && { water: data.water.features.length }),
|
||||
...(data.road && {
|
||||
road: data.road.all.features.length,
|
||||
forestRoad: data.road.small.features.length,
|
||||
}),
|
||||
...(data.forestRoadFile && {
|
||||
forestRoadFile: data.forestRoadFile.info.count,
|
||||
forestRoadFile_m: data.forestRoadFile.info.length_m,
|
||||
forestRoadFile_total: data.forestRoadFile.info.total,
|
||||
}),
|
||||
...(data.landslideZones && {
|
||||
landslideZone_points: data.landslideZones.info.points.length,
|
||||
landslideZone_parcels: data.landslideZones.parcels.features.length,
|
||||
landslideZone_missing: data.landslideZones.info.missing.length,
|
||||
}),
|
||||
...(data.place && { place: data.place.length }),
|
||||
...(data.zones &&
|
||||
Object.fromEntries(
|
||||
Object.entries(data.zones.summary).flatMap(([kind, value]) => [
|
||||
[`zone_${kind}`, value.count],
|
||||
[`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,
|
||||
}),
|
||||
...(data.hillshade && { hillshade_px: data.hillshade.image.naturalWidth }),
|
||||
...(data.landslide && { landslide_px: data.landslide.image.naturalWidth }),
|
||||
...Object.fromEntries(
|
||||
LINE_KEYS.flatMap((key) => {
|
||||
const got = data[key];
|
||||
return got
|
||||
? [
|
||||
[key, got.count],
|
||||
[`${key}_m`, got.length_m],
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -1,22 +1,25 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Layers.ts
|
||||
* D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 요구 395-2 · 440-8 · 442 · 443) — 지도 왼쪽 위 아이콘 한 줄
|
||||
* D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 요구 395-2 · 440-8 · 442 · 443) — 지도 왼쪽 위 아이콘 한 줄
|
||||
* (공용 지도 도구줄 `createMapTools` · 넘치면 가로 밀기 571-1 · 오른쪽 평면 도구줄과 따로).
|
||||
*
|
||||
* 겹 = 바탕(위성 · 음영기복 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 급경사 색) ·
|
||||
* 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 기존 임도 짐작(소로 · 미분류)) ·
|
||||
* 구역(지적도 · 보호 · 규제 구역 여덟 종 · 산림입지도(토양형) · 산사태위험지도 · 선 겹(단층 · 지질구조선 · 0차곡) ·
|
||||
* 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 기존 임도 짐작(소로 · 미분류) ·
|
||||
* 올린 임도망도(시군구 SHP · 옆 올리기 단추)) ·
|
||||
* 구역(지적도 · 보호 · 규제 구역 여덟 종 · 산림입지도(토양형) · 산사태위험지도 · 산사태취약지역(시도 표 점 · 지번 필지 테두리) ·
|
||||
* 선 겹(단층 · 지질구조선 · 0차곡) ·
|
||||
* 계류 100 m · 도로 300 m 띠 · 건물 · 지명).
|
||||
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
|
||||
* 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음 · 받기 실패 = 그 겹만 꺼짐 + 알림.
|
||||
* 그리기 차례(아래 → 위) = 위성(`scene.image`) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 →
|
||||
* 건물 → 등고선 → 도로 → 임도 짐작 → 세류 → 선 겹 → 지명 → 그은 선.
|
||||
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
|
||||
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
|
||||
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래).
|
||||
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { createLegend, type LegendItem } from "@ui/ui_template_flow_legend";
|
||||
import { createLegend } from "@ui/ui_template_flow_legend";
|
||||
import { pickFiles } from "@ui/ui_template_drop";
|
||||
import { createIconButton } from "@ui/ui_template_button";
|
||||
import { createMapTools } from "@ui/ui_template_map_tools";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
@@ -42,21 +45,19 @@ import {
|
||||
import {
|
||||
fetchPlanBands,
|
||||
fetchPlanCadastral,
|
||||
fetchPlanForestRoads,
|
||||
fetchPlanLandslideZones,
|
||||
fetchPlanLines,
|
||||
fetchPlanRaster,
|
||||
fetchPlanSheetLayer,
|
||||
fetchPlanSoils,
|
||||
fetchPlanZeroOrder,
|
||||
fetchPlanZones,
|
||||
type PlanBand,
|
||||
type PlanRaster,
|
||||
type PlanSoils,
|
||||
type PlanZones,
|
||||
uploadPlanForestRoads,
|
||||
} from "./D03_PlanAnalysis_Api";
|
||||
import {
|
||||
FOREST_ROAD_KINDS,
|
||||
GROUPS,
|
||||
LANDSLIDE_LEGEND,
|
||||
LAYERS,
|
||||
LINE_KEYS,
|
||||
LINE_LAYERS,
|
||||
@@ -65,43 +66,18 @@ import {
|
||||
ZONE_COLORS,
|
||||
drawRaster,
|
||||
fillLayer,
|
||||
hectare,
|
||||
loadImage,
|
||||
readPref,
|
||||
stroke,
|
||||
type LineLayerKey,
|
||||
type PlanLayerKey,
|
||||
} from "./D03_PlanAnalysis_UI_LayerDefs";
|
||||
import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend";
|
||||
import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
|
||||
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
|
||||
const PREF_KEY = "aislo:pref:d03.layers";
|
||||
/** 등고선을 받아 올 그은 선 둘레(m). */
|
||||
const CONTOUR_PAD_M = 600;
|
||||
/** 선 겹 — 그릴 선 · 받은 범위 안 가닥 · 길이 m. */
|
||||
interface PreparedLines {
|
||||
layer: PreparedLayer;
|
||||
count: number;
|
||||
length_m: number;
|
||||
}
|
||||
type Prepared = Record<LineLayerKey, PreparedLines> & {
|
||||
contour: PreparedLayer;
|
||||
/** 세류 · 하천(소하천 · 지방하천 · 연결선). */
|
||||
stream: [PreparedLayer, PreparedLayer];
|
||||
cadastral: PreparedLayer;
|
||||
/** 전체 · 그중 임도 짐작(소로 · 미분류) — `road` · `forestRoad` 가 함께 씀. */
|
||||
road: { all: PreparedLayer; small: PreparedLayer };
|
||||
slope: { raster: PlanRaster; image: HTMLImageElement };
|
||||
hillshade: { raster: PlanRaster; image: HTMLImageElement };
|
||||
landslide: { 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 }>;
|
||||
};
|
||||
type DataKey = keyof Prepared;
|
||||
|
||||
/** 겹 → 받는 자료(임도 짐작 = 도로 자료). */
|
||||
@@ -133,6 +109,7 @@ export function createPlanLayers(opts: {
|
||||
const on = readPref(PREF_KEY);
|
||||
const data: Partial<Prepared> = {};
|
||||
const pending = new Map<DataKey | "satellite", Promise<void>>();
|
||||
let uploading = false;
|
||||
let meta: VWorldMeta | null = null;
|
||||
let satellite: HTMLImageElement | null = null;
|
||||
|
||||
@@ -151,6 +128,19 @@ export function createPlanLayers(opts: {
|
||||
return button;
|
||||
}),
|
||||
);
|
||||
const upload = createIconButton({
|
||||
icon: "upload",
|
||||
title: "임도망도 SHP 올리기(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)",
|
||||
size: 32,
|
||||
onClick: () =>
|
||||
pickFiles(
|
||||
{ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true },
|
||||
(files) => void uploadRoads(files),
|
||||
),
|
||||
});
|
||||
upload.dataset.layer = "forestRoadUpload";
|
||||
const roadGroup = groups.find((group) => group.includes(buttons.get("forestRoadFile")!))!;
|
||||
roadGroup.splice(roadGroup.indexOf(buttons.get("forestRoadFile")!) + 1, 0, upload);
|
||||
const tools = createMapTools({ label: "지도 겹", groups });
|
||||
const bar = tools.root;
|
||||
bar.classList.add("d03-plan__layers");
|
||||
@@ -165,88 +155,6 @@ export function createPlanLayers(opts: {
|
||||
const loadedOf = (key: PlanLayerKey): boolean =>
|
||||
key === "satellite" ? !!satellite : !!data[dataKey(key)];
|
||||
|
||||
function legendRows(): LegendItem[][] {
|
||||
const rows: LegendItem[][] = [];
|
||||
if (on.slope && data.slope)
|
||||
rows.push(
|
||||
data.slope.raster.bands.map((band) => ({
|
||||
label: `${band.min_deg}${band.max_deg < 90 ? `~${band.max_deg}°` : "°↑"} ${(band.ratio * 100).toFixed(1)} %`,
|
||||
color: band.color,
|
||||
})),
|
||||
);
|
||||
if (on.landslide && data.landslide) rows.push(LANDSLIDE_LEGEND);
|
||||
if (on.zones && data.zones) {
|
||||
const items = Object.entries(data.zones.summary)
|
||||
.filter(([, kind]) => kind.count || kind.failed)
|
||||
.map(([key, kind]) => ({
|
||||
label: kind.failed
|
||||
? `${kind.label} 받지 못함`
|
||||
: `${kind.label} ${kind.count}곳 ${hectare(kind.area_m2)}`,
|
||||
color: `var(${ZONE_COLORS[key]})`,
|
||||
off: !!kind.failed,
|
||||
}));
|
||||
rows.push(
|
||||
items.length
|
||||
? items
|
||||
: [{ 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([
|
||||
{
|
||||
label: `계류 ${stream.dist_m} m 띠 ${hectare(stream.area_m2)} (${(stream.ratio * 100).toFixed(1)} %)`,
|
||||
color: "var(--draw-map-band-stream)",
|
||||
},
|
||||
{
|
||||
label: `도로 ${road.dist_m} m 띠 ${hectare(road.area_m2)} (${(road.ratio * 100).toFixed(1)} %)`,
|
||||
color: "var(--draw-map-band-road)",
|
||||
},
|
||||
]);
|
||||
}
|
||||
const lines = LINE_KEYS.flatMap((key) => {
|
||||
const got = on[key] && data[key];
|
||||
if (!got) return [];
|
||||
const { label, color, dash } = LINE_LAYERS[key];
|
||||
return [
|
||||
{
|
||||
label: `${label} ${got.count}가닥 ${(got.length_m / 1000).toFixed(2)} km`,
|
||||
color: `var(${color})`,
|
||||
kind: dash.length ? ("dash" as const) : ("line" as const),
|
||||
off: !got.count,
|
||||
},
|
||||
];
|
||||
});
|
||||
if (lines.length) rows.push(lines);
|
||||
const tail: LegendItem[] = [];
|
||||
if (on.water && data.water)
|
||||
tail.push({ label: `물 면 ${data.water.features.length}곳`, color: "var(--draw-map-water)" });
|
||||
if (on.building && data.building)
|
||||
tail.push({
|
||||
label: `건물 ${data.building.features.length.toLocaleString()}동`,
|
||||
color: "var(--draw-map-building)",
|
||||
});
|
||||
if (on.forestRoad && data.road)
|
||||
tail.push({
|
||||
label: `임도 짐작 ${data.road.small.features.length}가닥`,
|
||||
color: "var(--draw-map-forest-road)",
|
||||
kind: "dash",
|
||||
});
|
||||
if (tail.length) rows.push(tail);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function sync(): void {
|
||||
for (const def of LAYERS) {
|
||||
const button = buttons.get(def.key)!;
|
||||
@@ -256,7 +164,8 @@ export function createPlanLayers(opts: {
|
||||
button.title = title;
|
||||
button.setAttribute("aria-label", title);
|
||||
}
|
||||
const rows = meta ? legendRows() : [];
|
||||
upload.disabled = !meta || uploading;
|
||||
const rows = meta ? legendRows(on, data) : [];
|
||||
legend.replaceChildren(...rows.map((row) => createLegend(row)));
|
||||
legend.hidden = !rows.length;
|
||||
}
|
||||
@@ -304,6 +213,41 @@ export function createPlanLayers(opts: {
|
||||
return job;
|
||||
}
|
||||
|
||||
/** 올리기 — 받으면 그 겹을 켜고 바로 그림 · 못 받으면 옛 것 그대로 + 서버 까닭 알림. */
|
||||
async function uploadRoads(files: File[]): Promise<void> {
|
||||
if (!meta) return;
|
||||
const at = meta;
|
||||
const job = uploadPlanForestRoads(projectId, files).then((info) => {
|
||||
if (meta !== at) return;
|
||||
data.forestRoadFile = {
|
||||
layer: prepareLayer(info.collection, createNormalizer(at)),
|
||||
info,
|
||||
};
|
||||
on.forestRoadFile = true;
|
||||
writeByKey(PREF_KEY, JSON.stringify(on));
|
||||
showToast(
|
||||
`임도망도 받음 — 범위 안 ${info.count}가닥 ${(info.length_m / 1000).toFixed(2)} km (파일 ${info.total}가닥)`,
|
||||
"success",
|
||||
);
|
||||
});
|
||||
uploading = true;
|
||||
pending.set("forestRoadFile", job);
|
||||
sync();
|
||||
try {
|
||||
await job;
|
||||
} catch (error) {
|
||||
showToast(
|
||||
`임도망도 올리지 못함 — ${error instanceof Error ? error.message : String(error)}`,
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
uploading = false;
|
||||
pending.delete("forestRoadFile");
|
||||
sync();
|
||||
map.redrawBase();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLayer(key: PlanLayerKey, at: VWorldMeta): Promise<void> {
|
||||
const normalizer = createNormalizer(at);
|
||||
const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer =>
|
||||
@@ -355,6 +299,14 @@ export function createPlanLayers(opts: {
|
||||
pick(collection, (props) => FOREST_ROAD_KINDS.has(String(props["도로구분"]))),
|
||||
),
|
||||
};
|
||||
} else if (key === "forestRoadFile") {
|
||||
const info = await fetchPlanForestRoads(projectId);
|
||||
data.forestRoadFile = { layer: prepare(info.collection), info };
|
||||
} else if (key === "landslideZones") {
|
||||
const info = await fetchPlanLandslideZones(projectId);
|
||||
data.landslideZones = { parcels: prepare(info.parcels), info };
|
||||
if (info.missing.length)
|
||||
showToast(`산사태취약지역 — ${info.missing.join(" · ")} 자료 없음`, "info");
|
||||
} else if (key === "slope" || key === "hillshade" || key === "landslide") {
|
||||
const raster = await fetchPlanRaster(projectId, key);
|
||||
data[key] = { raster, image: await loadImage(raster.image) };
|
||||
@@ -566,6 +518,15 @@ export function createPlanLayers(opts: {
|
||||
context.setLineDash([]);
|
||||
},
|
||||
},
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
const got = on.forestRoadFile && data.forestRoadFile;
|
||||
if (!got) return;
|
||||
stroke(context, map, got.layer, "rgba(0, 0, 0, 0.55)", 4);
|
||||
stroke(context, map, got.layer, themeColor("--draw-map-forest-road-file", "#ea580c"), 2.4);
|
||||
},
|
||||
},
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
@@ -587,6 +548,31 @@ export function createPlanLayers(opts: {
|
||||
context.setLineDash([]);
|
||||
},
|
||||
})),
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
const got = on.landslideZones && data.landslideZones;
|
||||
if (!got || !meta) return;
|
||||
const color = themeColor("--draw-map-landslide-zone", "#e11d48");
|
||||
// 필지 = 「이 필지 안 어딘가」 테두리만(지정 넓이 ≪ 필지 · 칠하지 않음)
|
||||
context.setLineDash([6, 3]);
|
||||
stroke(context, map, got.parcels, "rgba(255, 255, 255, 0.7)", 3.2);
|
||||
stroke(context, map, got.parcels, color, 1.8);
|
||||
context.setLineDash([]);
|
||||
const normalizer = createNormalizer(meta);
|
||||
context.fillStyle = color;
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.lineWidth = 1.5;
|
||||
for (const point of got.info.points) {
|
||||
const [x, y] = lonLatToScreen(normalizer, map.view, point.lon, point.lat);
|
||||
if (x < -10 || y < -10 || x > map.view.width + 10 || y > map.view.height + 10) continue;
|
||||
context.beginPath();
|
||||
context.arc(x, y, 5, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
@@ -629,47 +615,6 @@ export function createPlanLayers(opts: {
|
||||
await Promise.all(LAYERS.map((def) => ensure(def.key)));
|
||||
}
|
||||
|
||||
const counts = (): Record<string, number> => ({
|
||||
...(data.building && { building: data.building.features.length }),
|
||||
...(data.water && { water: data.water.features.length }),
|
||||
...(data.road && {
|
||||
road: data.road.all.features.length,
|
||||
forestRoad: data.road.small.features.length,
|
||||
}),
|
||||
...(data.place && { place: data.place.length }),
|
||||
...(data.zones &&
|
||||
Object.fromEntries(
|
||||
Object.entries(data.zones.summary).flatMap(([kind, value]) => [
|
||||
[`zone_${kind}`, value.count],
|
||||
[`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,
|
||||
}),
|
||||
...(data.hillshade && { hillshade_px: data.hillshade.image.naturalWidth }),
|
||||
...(data.landslide && { landslide_px: data.landslide.image.naturalWidth }),
|
||||
...Object.fromEntries(
|
||||
LINE_KEYS.flatMap((key) => {
|
||||
const got = data[key];
|
||||
return got
|
||||
? [
|
||||
[key, got.count],
|
||||
[`${key}_m`, got.length_m],
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
bar,
|
||||
legend,
|
||||
@@ -678,7 +623,7 @@ export function createPlanLayers(opts: {
|
||||
state: () => ({
|
||||
on: { ...on },
|
||||
loaded: LAYERS.map((def) => def.key).filter(loadedOf),
|
||||
counts: counts(),
|
||||
counts: layerCounts(data),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# 이름: 광주광역시 산사태취약지역현황('26년 6월기준)
|
||||
# 받은 날: 2026-10-29
|
||||
# 기준일: 2026-06-02
|
||||
# 출처: https://www.data.go.kr/data/15012196/fileData.do
|
||||
# 이용 조건: 공공데이터포털 이용허락범위 제한 없음
|
||||
관할,조사지(시도),조사지(시군구),조사지(동),지번,위도(도),위도(분),위도(초),경도(도),경도(분),경도(초),취약지역유형,지정면적(제곱미터),거리(미터),데이터기준일자
|
||||
동구,광주광역시,동구,운림동,산269임,35,7,41.799,126,57,1,토석류,6368,900,2026-06-02
|
||||
동구,광주광역시,동구,지산동,산60-1임,35,8,59.499,126,57,6,토석류,6227,100,2026-06-02
|
||||
광산구,광주광역시,광산구,광산동,산 85 임,35,14,57.61,126,44,41.963,산사태,441.1,0,2026-06-02
|
||||
광산구,광주광역시,광산구,광산동,산153-1임,35,13,50.6,126,44,28.6,산사태,3412.5,0,2026-06-02
|
||||
광산구,광주광역시,광산구,임곡동,산88임,35,12,40.899,126,44,38.7,토석류,4949,60,2026-06-02
|
||||
광산구,광주광역시,광산구,임곡동,산85임,35,12,44.299,126,44,27.1,토석류,6915,60,2026-06-02
|
||||
광산구,광주광역시,광산구,명도동,산67임,35,10,17.899,126,41,36.2,토석류,4150,131,2026-06-02
|
||||
광산구,광주광역시,광산구,명도동,산59임,35,10,25.472,126,41,49.731,산사태,5708,0,2026-06-02
|
||||
광산구,광주광역시,광산구,명도동,산62임,35,10,20.587,126,41,47.113,산사태,1272,0,2026-06-02
|
||||
광산구,광주광역시,광산구,양산동,414전,35,11,59.546,126,41,15.011,토석류,4180,0,2026-06-02
|
||||
광산구,광주광역시,광산구,산월동,산64-1임,35,11,53.356,126,50,55.016,산사태,306,0,2026-06-02
|
||||
광산구,광주광역시,광산구,박호동,산162임,35,9,50.799,126,44,27.8,토석류,6330,0,2026-06-02
|
||||
광산구,광주광역시,광산구,산월동,산34-3임,35,12,19.36,126,50,51.297,산사태,60.21,0,2026-06-02
|
||||
광산구,광주광역시,광산구,지평동,산57-1임,35,8,59.2,126,43,17.388,토석류,3796,100,2026-06-02
|
||||
동구,광주광역시,동구,월남동,산29임,35,6,12.699,126,56,36,토석류,2249,100,2026-06-02
|
||||
북구,광주광역시,북구,효령동,880-10구,35,14,18.555,126,54,39.057,토석류,1239.52,220,2026-06-02
|
||||
북구,광주광역시,북구,충효동,산24임,35,10,19.449,127,0,56.583,토석류,1080,0,2026-06-02
|
||||
북구,광주광역시,북구,각화동,산71임,35,18,42.32,126,94,39.35,토석류,900,0,2026-06-02
|
||||
북구,광주광역시,북구,금곡동,994답,35,10,3.12,126,58,39.729,토석류,1235.4,0,2026-06-02
|
||||
광산구,광주광역시,광산구,사호동,산112-1임,35,12,49.236,126,43,46.723,산사태,161,0,2026-06-02
|
||||
북구,광주광역시,북구,월출동,산2임,35,14,52.8,126,52,22,산사태,211.65616,0,2026-06-02
|
||||
남구,광주광역시,남구,주월동,450-91대,35,7,34.499,126,53,30.6,토석류,1416,0,2026-06-02
|
||||
남구,광주광역시,남구,송하동,산89-2임,35,6,41.399,126,52,58.9,토석류,2204.8,0,2026-06-02
|
||||
남구,광주광역시,남구,방림동,산16-1임,35,7,47.599,126,55,20.2,토석류,638.4,33,2026-06-02
|
||||
동구,광주광역시,동구,용산동,98-3유,35,6,43.799,126,55,55.8,산사태,1248,30,2026-06-02
|
||||
동구,광주광역시,동구,산수동,산52-1임,35,9,26.818,126,56,32.991,토석류,65346,500,2026-06-02
|
||||
동구,광주광역시,동구,지산동,산56-1임,35,9,0.999,126,57,4.6,토석류,703,100,2026-06-02
|
||||
동구,광주광역시,동구,소태동,산102임,35,6,55.799,126,56,59.5,토석류,1248,450,2026-06-02
|
||||
동구,광주광역시,동구,운림동,196종,35,8,16.699,126,57,57,토석류,1206,85,2026-06-02
|
||||
동구,광주광역시,동구,용산동,산1-2,35,7,32.178,126,55,46.029,산사태,583,300,2026-06-02
|
||||
광산구,광주광역시,광산구,광산동,산 135 임,35,14,6.899,126,44,45.1,토석류,2808.4,0,2026-06-02
|
||||
광산구,광주광역시,광산구,두정동,578구,35,13,43.4,126,45,42.542,토석류,2368,0,2026-06-02
|
||||
광산구,광주광역시,광산구,사호동,산136임,35,12,55.699,126,44,11.2,토석류,4833,0,2026-06-02
|
||||
광산구,광주광역시,광산구,사호동,산107-2임,35,12,46.099,126,43,37,토석류,1248,0,2026-06-02
|
||||
광산구,광주광역시,광산구,왕동,산55임,35,12,15.53,126,42,43.672,산사태,6333.2,0,2026-06-02
|
||||
광산구,광주광역시,광산구,북산동,산50-2임,35,10,39.392,126,42,38.367,산사태,1968.3,0,2026-06-02
|
||||
광산구,광주광역시,광산구,내산동,산241임,35,8,6.198,126,39,28.156,산사태,8064.42,0,2026-06-02
|
||||
광산구,광주광역시,광산구,대산동,산67-1임,35,8,36.407,126,40,7.875,산사태,398.46,0,2026-06-02
|
||||
광산구,광주광역시,광산구,동호동,산19-1,35,35,56.459,128,6,38.029,토석류,142.09,0,2026-06-02
|
||||
남구,광주광역시,남구,임암동,323종,35,6,26.199,126,52,20.7,토석류,2158.4,0,2026-06-02
|
||||
남구,광주광역시,남구,진월동,산234-1임,35,7,19.899,126,53,33.5,토석류,1666.6667,0,2026-06-02
|
||||
북구,광주광역시,북구,두암동,산82임,35,10,13.099,126,56,31.1,토석류,1532,20,2026-06-02
|
||||
북구,광주광역시,북구,화암동,산97임,35,9,20.804,126,57,30.225,토석류,795.6,120,2026-06-02
|
||||
북구,광주광역시,북구,청풍동,산314임,35,17,8.69,126,95,81.66,토석류,757,0,2026-06-02
|
||||
북구,광주광역시,북구,망월동,산14임,35,12,32.199,126,57,9.9,토석류,623.3333,50,2026-06-02
|
||||
북구,광주광역시,북구,두암동,200답,35,10,8.599,126,56,28.6,토석류,88.07,50,2026-06-02
|
||||
북구,광주광역시,북구,양산동,산99-1임,35,11,35.756,126,52,33.819,산사태,0,0,2026-06-02
|
||||
광산구,광주광역시,광산구,서봉동,산294-2,35,9,9.606,126,46,1.455,토석류,1518,0,2026-06-02
|
||||
광산구,광주광역시,광산구,산막동,산61임,35,11,34.111,126,45,32.951,산사태,480.58,0,2026-06-02
|
||||
북구,광주광역시,북구,생용동,산89임,35,22,31.54,126,90,52.56,토석류,815,0,2026-06-02
|
||||
북구,광주광역시,북구,청풍동,산245임,35,10,36.8,126,57,31.4,산사태,277.07968,0,2026-06-02
|
||||
광산구,광주광역시,광산구,임곡동,산87-1임,35,12,49,126,44,34.5,산사태,185.26,0,2026-06-02
|
||||
북구,광주광역시,북구,문흥동,산16임,35,11,47.2,126,56,13.6,토석류,716.21,0,2026-06-02
|
||||
남구,광주광역시,남구,진월동,산206-1임,35,6,58.599,126,53,28.1,토석류,440,194,2026-06-02
|
||||
남구,광주광역시,남구,송하동,541-24구,35,6,58.199,126,52,29.3,토석류,823.3333,95,2026-06-02
|
||||
남구,광주광역시,남구,석정동,산23-1,35,5,16.48,126,49,49,산사태,177,28,2026-06-02
|
||||
동구,광주광역시,동구,월남동,산130임,35,6,10.899,126,56,5.7,토석류,2325,100,2026-06-02
|
||||
동구,광주광역시,동구,소태동,산275-1임,35,7,43.2,126,56,56,토석류,146.78,100,2026-06-02
|
||||
동구,광주광역시,동구,선교동,11전,35,5,32.499,126,57,25.3,토석류,1424,100,2026-06-02
|
||||
동구,광주광역시,동구,월남동,609-2대,35,6,18.88,126,55,33.095,토석류,3836,700,2026-06-02
|
||||
북구,광주광역시,북구,망월동,산108임,35,11,15.029,126,57,17.12,토석류,22711,0,2026-06-02
|
||||
광산구,광주광역시,광산구,내산동,산 168 임,35,8,5.699,126,39,25.9,토석류,1804,0,2026-06-02
|
||||
북구,광주광역시,북구,청풍동,산256임,35,10,28.286,126,57,35.111,산사태,0,0,2026-06-02
|
||||
광산구,광주광역시,광산구,광산동,산29임,35,15,5.7,126,44,44.1,산사태,1651.6,0,2026-06-02
|
||||
광산구,광주광역시,광산구,등임동,산96번지,35,10,49.159,126,45,44.236,토석류,3286,0,2026-06-02
|
||||
광산구,광주광역시,광산구,두정동,산48임,35,13,39.699,126,45,29.9,토석류,2937.6,100,2026-06-02
|
||||
광산구,광주광역시,광산구,북산동,3-2임,35,11,12.882,126,42,47.093,토석류,164,50,2026-06-02
|
||||
북구,광주광역시,북구,장등동,산3-1임,35,12,44.9,126,56,28.1,산사태,0,0,2026-06-02
|
||||
북구,광주광역시,북구,화암동,산95임,35,9,22.599,126,57,27.8,토석류,807.24,65,2026-06-02
|
||||
북구,광주광역시,북구,망월동,산 17 임,35,12,25.922,126,57,14.606,토석류,420,10,2026-06-02
|
||||
북구,광주광역시,북구,각화동,산20-3임,35,11,30.099,126,56,22.1,토석류,915,30,2026-06-02
|
||||
광산구,광주광역시,광산구,임곡동,산81,35,12,48.96,126,44,24.909,토석류,2131,0,2026-06-02
|
||||
광산구,광주광역시,광산구,선동,산12-2임,35,12,13.8,126,45,10.099,산사태,2715.08,0,2026-06-02
|
||||
광산구,광주광역시,광산구,서봉동,산84-1임,35,9,17.5,126,44,55.6,산사태,298.26,0,2026-06-02
|
||||
동구,광주광역시,동구,용연동,산53임,35,5,40.8,126,57,57,산사태,105.63,100,2026-06-02
|
||||
광산구,광주광역시,광산구,동호동,산30임,35,10,57.312,126,41,33.1,산사태,456,0,2026-06-02
|
||||
동구,광주광역시,동구,운림동,산256임,35,7,29.3,126,57,57,토석류,129.74,100,2026-06-02
|
||||
남구,광주광역시,남구,주월동,산198임,35,7,32.199,126,54,7.6,토석류,3492,150,2026-06-02
|
||||
남구,광주광역시,남구,방림동,41-5 도,35,7,44.799,126,55,26.3,토석류,2866,0,2026-06-02
|
||||
남구,광주광역시,남구,주월동,산169임,35,7,26.499,126,53,33.2,토석류,1896,0,2026-06-02
|
||||
남구,광주광역시,남구,덕남동,산 80번지,35,5,9.24,126,54,54,산사태,353,22,2026-06-02
|
||||
광산구,광주광역시,광산구,우산동,산101,35,9,13.89,126,48,24.23,산사태,200,10,2026-06-02
|
||||
동구,광주광역시,동구,선교동,472-19천,35,5,29.457,126,57,12.094,토석류,7343,60,2026-06-02
|
||||
동구,광주광역시,동구,운림동,산 143-1 임,35,7,43.599,126,58,13.8,토석류,6614,300,2026-06-02
|
||||
동구,광주광역시,동구,지산동,산24-3임,35,9,0.482,126,56,34.417,산사태,249,100,2026-06-02
|
||||
동구,광주광역시,동구,소태동,산238-1임,35,6,57.406,126,56,55.999,토석류,6705,100,2026-06-02
|
||||
동구,광주광역시,동구,운림동,198전,35,8,19.099,126,57,8.4,토석류,1206,90,2026-06-02
|
||||
동구,광주광역시,동구,운림동,산267임,35,7,40.299,126,57,7.8,토석류,8131,900,2026-06-02
|
||||
동구,광주광역시,동구,월남동,456대,35,6,19.063,126,55,35.315,토석류,5345,700,2026-06-02
|
||||
동구,광주광역시,동구,내남동,878도,35,5,50.499,126,55,53.8,토석류,47375,30,2026-06-02
|
||||
동구,광주광역시,동구,내남동,산122임,35,4,55.956,126,55,42.807,산사태,248,1400,2026-06-02
|
||||
광산구,광주광역시,광산구,광산동,산 135 임,35,14,17.799,126,44,39.2,토석류,4554,50,2026-06-02
|
||||
광산구,광주광역시,광산구,광산동,산87-2임,35,14,53.4,126,44,38.5,산사태,248.9,0,2026-06-02
|
||||
광산구,광주광역시,광산구,선동,산100-1임,35,11,52.5,126,44,24.9,산사태,1140.66,0,2026-06-02
|
||||
광산구,광주광역시,광산구,오산동,산4임,35,13,59.4,126,43,40.5,산사태,495.08,0,2026-06-02
|
||||
광산구,광주광역시,광산구,왕동,산100-3임,35,11,55.607,126,43,29.295,토석류,1533.6173,0,2026-06-02
|
||||
광산구,광주광역시,광산구,북산동,산48-1임,35,10,39.073,126,42,40.313,산사태,986.64,0,2026-06-02
|
||||
광산구,광주광역시,광산구,박호동,산152임,35,9,59.199,126,44,32.3,토석류,6662,0,2026-06-02
|
||||
광산구,광주광역시,광산구,박호동,산156-1임,35,9,53.879,126,44,31.411,토석류,5356,25,2026-06-02
|
||||
광산구,광주광역시,광산구,임곡동,산102-3,35,12,38.761,126,44,42.524,산사태,241,0,2026-06-02
|
||||
북구,광주광역시,북구,각화동,산71임,35,11,3.699,126,56,38.5,토석류,0,0,2026-06-02
|
||||
광산구,광주광역시,광산구,지평동,산37-4임,35,9,7.199,126,44,23.2,산사태,3940,0,2026-06-02
|
||||
북구,광주광역시,북구,화암동,397-1전,35,9,31.399,126,57,45.4,토석류,844,400,2026-06-02
|
||||
북구,광주광역시,북구,두암동,산95-2임,35,10,8.263,126,56,25.757,산사태,0,0,2026-06-02
|
||||
북구,광주광역시,북구,신안동,310-6대,35,10,22.5,126,53,44.626,토석류,350,0,2026-06-02
|
||||
북구,광주광역시,북구,장등동,산287임,35,12,5.499,126,56,9.3,토석류,0,0,2026-06-02
|
||||
광산구,광주광역시,광산구,동호동,산54-1임,35,11,12.3,126,41,1.399,산사태,78,0,2026-06-02
|
||||
북구,광주광역시,북구,두암동,산98-1임,35,10,8.2,126,56,29.9,산사태,237.71153,0,2026-06-02
|
||||
광산구,광주광역시,광산구,광산동,산29임,35,35,5.052,128,6,47.818,산사태,344.82,0,2026-06-02
|
||||
광산구,광주광역시,광산구,내산동,산33-1임,35,13,30.56,126,66,52.88,토석류,3732,0,2026-06-02
|
||||
광산구,광주광역시,광산구,명도동,산49-1임,35,17,92.99,126,69,89.77,토석류,4252,0,2026-06-02
|
||||
광산구,광주광역시,광산구,명도동,산58임,35,17,70.78,126,69,76.35,토석류,2782,0,2026-06-02
|
||||
광산구,광주광역시,광산구,산월동,산36임,35,20,59.65,126,84,84.86,산사태,408,0,2026-06-02
|
||||
광산구,광주광역시,광산구,선암동,산15-6임,35,15,61.24,126,77,4.24,토석류,2613,0,2026-06-02
|
||||
광산구,광주광역시,광산구,오산동,산33임,35,22,39.87,126,72,37.31,산사태,203,0,2026-06-02
|
||||
광산구,광주광역시,광산구,임곡동,산33-2임,35,22,9.89,126,75,10.55,토석류,550,0,2026-06-02
|
||||
광산구,광주광역시,광산구,임곡동,산31-2임,35,21,77.22,126,75,15,산사태,104,0,2026-06-02
|
||||
광산구,광주광역시,광산구,명도동,산67-4임,35,17,11.58,126,69,47.22,산사태,1384,0,2026-06-02
|
||||
광산구,광주광역시,광산구,광산동,산84임,35,24,98.72,126,74,64.3,산사태,1026,0,2026-06-02
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -64,10 +64,12 @@ def test_d03_parts():
|
||||
("water", "false"),
|
||||
("road", "false"),
|
||||
("forestRoad", "false"),
|
||||
("forestRoadFile", "false"),
|
||||
("cadastral", "false"),
|
||||
("zones", "false"),
|
||||
("soils", "false"),
|
||||
("landslide", "false"),
|
||||
("landslideZones", "false"),
|
||||
("faults", "false"),
|
||||
("lineaments", "false"),
|
||||
("zeroOrder", "false"),
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
"""D 노선분석 GIS 겹 더함 3(PLAN 584-3) — 올린 임도망도 SHP(zip · 낱 파일 묶음 · prj 없으면 5179 ·
|
||||
cp949 · 받은 범위로 자름 · 범위 밖 = 받지 않음 · 저장본) · 산사태취약지역 시도 표(머리 · 열 별칭 ·
|
||||
도분초 · 지번) · 점 · 지적 필지 맞춤 · 표 없는 시도 · 화면 겹 정의.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_584_3_plan_forest_road_zones.py -q
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import geopandas as gpd
|
||||
import pytest
|
||||
from shapely.geometry import LineString, box
|
||||
|
||||
from common_util.common_util_crs import tm_to_lonlat
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_ForestRoad as fr
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_LandslideZone as lz
|
||||
|
||||
EPSG = 5186
|
||||
BOX = (230000.0, 450000.0, 232000.0, 451000.0) # 2 × 1 km
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
LINES = [
|
||||
LineString([(229000, 450500), (231000, 450500)]), # 반만 안 = 1,000 m
|
||||
LineString([(231500, 450100), (231500, 450400)]), # 300 m
|
||||
LineString([(250000, 470000), (251000, 470000)]), # 범위 밖
|
||||
]
|
||||
|
||||
|
||||
def _shp_files(folder: Path, lines: list[LineString], prj: bool = True) -> list[tuple[str, bytes]]:
|
||||
"""작업 좌표계 m 선 → 5179 SHP(cp949 · cpg 없음) 낱 파일."""
|
||||
folder.mkdir(parents=True)
|
||||
frame = gpd.GeoDataFrame(
|
||||
{"임도명": [f"임도{i}" for i in range(len(lines))]}, geometry=lines, crs=EPSG
|
||||
).to_crs(5179)
|
||||
frame.to_file(folder / "47250.shp", encoding="cp949")
|
||||
(folder / "47250.cpg").unlink(missing_ok=True)
|
||||
if not prj:
|
||||
(folder / "47250.prj").unlink()
|
||||
return [(p.name, p.read_bytes()) for p in sorted(folder.iterdir())]
|
||||
|
||||
|
||||
def _zip(members: dict[str, bytes]) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as zf:
|
||||
for name, data in members.items():
|
||||
zf.writestr(name, data)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
# ── 임도망도 ──
|
||||
|
||||
|
||||
def test_save_roads_files_clip_and_saved(tmp_path) -> None:
|
||||
files = _shp_files(tmp_path / "shp", LINES)
|
||||
got = fr.save_roads(tmp_path, files, BOX, EPSG)
|
||||
assert (got["count"], got["total"]) == (2, 3)
|
||||
assert got["length_m"] == pytest.approx(1300, abs=1)
|
||||
assert got["crs"] == ["EPSG:5179"]
|
||||
assert got["collection"]["features"][0]["properties"]["임도명"] == "임도0" # cp949 dbf
|
||||
assert got["source"]["files"] == [name for name, _ in files]
|
||||
assert (tmp_path / fr.FOLDER / fr.SOURCE_ZIP).is_file()
|
||||
assert not list((tmp_path / fr.FOLDER).glob("*.new"))
|
||||
assert fr.road_layer(tmp_path, BOX, EPSG) == json.loads(json.dumps(got)) # 다시 열면 저장본
|
||||
|
||||
|
||||
def test_zip_in_folder_and_missing_prj_is_5179(tmp_path) -> None:
|
||||
files = _shp_files(tmp_path / "shp", LINES, prj=False)
|
||||
data = _zip({f"sub/{name}": body for name, body in files}) # 폴더 안이어도 이름만
|
||||
got = fr.save_roads(tmp_path, [("roads.zip", data)], BOX, EPSG)
|
||||
assert got["count"] == 2 and got["crs"] == ["EPSG:5179"]
|
||||
|
||||
|
||||
def test_bad_uploads_keep_old(tmp_path) -> None:
|
||||
files = _shp_files(tmp_path / "shp", LINES)
|
||||
first = fr.save_roads(tmp_path, files, BOX, EPSG)
|
||||
stamp = (tmp_path / fr.FOLDER / fr.SOURCE_ZIP).stat().st_mtime_ns
|
||||
bad = [
|
||||
([("a.zip", b"not a zip")], "zip 파일을 열지"),
|
||||
([(n, d) for n, d in files if not n.endswith(".dbf")], ".dbf"),
|
||||
([("a.zip", _zip({"readme.txt": b"x"}))], ".shp 파일이 없습니다"),
|
||||
(_shp_files(tmp_path / "far", [LINES[2]]), "받은 범위 안에 임도가 없습니다"),
|
||||
]
|
||||
for upload, text in bad:
|
||||
with pytest.raises(ValueError, match=re.escape(text)):
|
||||
fr.save_roads(tmp_path, upload, BOX, EPSG)
|
||||
assert (tmp_path / fr.FOLDER / fr.SOURCE_ZIP).stat().st_mtime_ns == stamp # 옛 것 그대로
|
||||
assert fr.road_layer(tmp_path, BOX, EPSG)["count"] == first["count"]
|
||||
assert not list((tmp_path / fr.FOLDER).glob("*.new"))
|
||||
|
||||
|
||||
def test_no_upload_is_not_found(tmp_path) -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
fr.road_layer(tmp_path, BOX, EPSG)
|
||||
|
||||
|
||||
def test_upload_limit() -> None:
|
||||
router = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_Router.py").read_text("utf-8")
|
||||
assert fr.MAX_BYTES == 100 * 1024 * 1024
|
||||
assert "status_code=413" in router and "forest_road.MAX_BYTES" in router
|
||||
|
||||
|
||||
# ── 산사태취약지역 표 ──
|
||||
|
||||
|
||||
def test_jibun_key() -> None:
|
||||
assert lz.jibun_key("산42임") == (True, 42, 0)
|
||||
assert lz.jibun_key("산77-1번지") == (True, 77, 1)
|
||||
assert lz.jibun_key("130-61임, 파도리 산65") == (False, 130, 61)
|
||||
assert lz.jibun_key("산 46 임") == (True, 46, 0)
|
||||
assert lz.jibun_key("외 2필지") is None
|
||||
|
||||
|
||||
def test_tables_head_and_columns() -> None:
|
||||
files = sorted(lz.DATA_DIR.glob("*.csv"))
|
||||
assert [p.name.split("_")[0] for p in files] == ["29", "44", "47", "48"]
|
||||
for path in files:
|
||||
head, rows = lz.read_table(path)
|
||||
assert head["출처"].startswith("https://www.data.go.kr/data/"), path.name
|
||||
assert head["받은 날"] and head["기준일"] and head["이름"], path.name
|
||||
assert rows and all(row["sgg"] and row["jibun"] for row in rows), path.name
|
||||
assert sum(row["key"] is None for row in rows) <= 5, path.name # 「외 2필지」 꼴만
|
||||
_, gb = lz.read_table(lz.table_of("47"))
|
||||
assert len(gb) == 8187 and all(row["lon"] for row in gb)
|
||||
first = gb[0] # 포항시 남구 연일읍 자명리 산42임 36°0'41.673" 129°18'40.529"
|
||||
assert first["lat"] == pytest.approx(36 + 41.673 / 3600)
|
||||
assert first["lon"] == pytest.approx(129 + 18 / 60 + 40.529 / 3600)
|
||||
assert (first["kind"], first["area_m2"]) == ("토석류", 182.0)
|
||||
_, gn = lz.read_table(lz.table_of("48"))
|
||||
assert gn and all(row["lon"] is None for row in gn) # 경남 = 지번만
|
||||
_, gj = lz.read_table(lz.table_of("29"))
|
||||
assert gj[0]["emd"] and gj[0]["ri"] == "" # 광주 = 동 · 리 없음
|
||||
assert lz.table_of("43") is None # 충북 = 표 없음
|
||||
|
||||
|
||||
# ── 점 · 필지 맞춤 ──
|
||||
|
||||
|
||||
def _parcel(x: float, gbn: str, main: str, sub: str, sgg: str = "상주시") -> dict:
|
||||
ring = [
|
||||
list(tm_to_lonlat(px, py, EPSG))
|
||||
for px, py in box(x, 450200, x + 100, 450300).exterior.coords
|
||||
]
|
||||
return {
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Polygon", "coordinates": [ring]},
|
||||
"properties": {
|
||||
"sido_cd": "47",
|
||||
"sido_nm": "경상북도",
|
||||
"sgg_nm": sgg,
|
||||
"emd_nm": "모동면",
|
||||
"ri_nm": "정양리",
|
||||
"gbn_cd": gbn,
|
||||
"mnnm": main,
|
||||
"slno": sub,
|
||||
"jibun": f"{'산' if gbn == '2' else ''}{int(main)}임",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
COLS = (
|
||||
"연번,시도,시군구,읍면동,리,지번,(위도)도,(위도)분,(위도)초,(경도)도,(경도)분,(경도)초,"
|
||||
"취약지역유형,지정면적(제곱미터),데이터기준일자"
|
||||
)
|
||||
|
||||
|
||||
def _csv(folder: Path, rows: list[str]) -> Path:
|
||||
path = folder / "47_경상북도.csv"
|
||||
head = [
|
||||
"# 이름: 시험",
|
||||
"# 받은 날: 2026-10-29",
|
||||
"# 기준일: 2026-06-04",
|
||||
"# 출처: https://www.data.go.kr/data/1/fileData.do",
|
||||
]
|
||||
path.write_text("\n".join([*head, COLS, *rows]) + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _dms(value: float) -> str:
|
||||
d = int(value)
|
||||
m = int((value - d) * 60)
|
||||
return f"{d},{m},{(value - d - m / 60) * 3600:.4f}"
|
||||
|
||||
|
||||
def _row(n: int, sgg: str, jibun: str, x: float, y: float, kind: str, area: int) -> str:
|
||||
lon, lat = tm_to_lonlat(x, y, EPSG)
|
||||
return f"{n},경상북도,{sgg},모동면,정양리,{jibun},{_dms(lat)},{_dms(lon)},{kind},{area},"
|
||||
|
||||
|
||||
def test_match_points_parcels_missing(tmp_path) -> None:
|
||||
table = _csv(
|
||||
tmp_path,
|
||||
[
|
||||
_row(1, "상주시", "산42임", 231000, 450500, "토석류", 55),
|
||||
_row(2, "상주시", "산42임", 231000, 450500, "산사태", 405),
|
||||
_row(3, "상주시", "산 42-1", 240000, 460000, "토석류", 300), # 점 = 범위 밖
|
||||
_row(4, "상주 시", "42임", 240000, 460000, "토석류", 10), # 띄어쓰기 무시
|
||||
],
|
||||
)
|
||||
parcels = [
|
||||
_parcel(230500, "2", "0042", "0000"), # 줄 1 · 2
|
||||
_parcel(230700, "2", "0042", "0001"), # 줄 3
|
||||
_parcel(230900, "1", "0042", "0000"), # 줄 4(산 아님)
|
||||
_parcel(231100, "2", "0043", "0000"), # 없음
|
||||
]
|
||||
got = lz.match_zones(parcels, {"47": table, "43": None}, BOX, EPSG)
|
||||
assert len(got["points"]) == 2 # 범위 안 점만
|
||||
assert got["points"][0]["place"] == "상주시 모동면 정양리 산42임"
|
||||
props = [f["properties"] for f in got["parcels"]["features"]]
|
||||
assert [p["count"] for p in props] == [2, 1, 1]
|
||||
assert props[0]["kinds"] == ["산사태", "토석류"] and props[0]["area_m2"] == 460.0
|
||||
assert got["missing"] == ["43"] # 지적에 이름 없으면 코드
|
||||
assert got["sidos"][0]["located"] == 4 and got["sidos"][0]["basis"] == "2026-06-04"
|
||||
|
||||
|
||||
def test_zone_layer_saved_and_no_cadastral(tmp_path, monkeypatch) -> None:
|
||||
_csv(tmp_path, [_row(1, "상주시", "산42임", 231000, 450500, "토석류", 55)])
|
||||
monkeypatch.setattr(lz, "DATA_DIR", tmp_path)
|
||||
cadastral = tmp_path / "cad.geojson"
|
||||
cadastral.write_text(json.dumps({"features": [_parcel(230500, "2", "42", "0")]}), "utf-8")
|
||||
root = tmp_path / "project"
|
||||
got = lz.zone_layer(root, cadastral, BOX, EPSG)
|
||||
assert got["cadastral"] and len(got["parcels"]["features"]) == 1 and got["missing"] == []
|
||||
assert len(got["points"]) == 1
|
||||
calls: list = []
|
||||
monkeypatch.setattr(lz, "match_zones", lambda *a: calls.append(a))
|
||||
assert lz.zone_layer(root, cadastral, BOX, EPSG) == got and not calls # 저장본
|
||||
monkeypatch.undo()
|
||||
monkeypatch.setattr(lz, "DATA_DIR", tmp_path)
|
||||
alone = lz.zone_layer(root, None, BOX, EPSG) # 지적 못 받음 = 점만 · 모든 표
|
||||
assert not alone["cadastral"] and alone["parcels"]["features"] == []
|
||||
assert len(alone["points"]) == 1 and [s["code"] for s in alone["sidos"]] == ["47"]
|
||||
|
||||
|
||||
# ── 화면 ──
|
||||
|
||||
|
||||
def test_screen_defs() -> None:
|
||||
folder = ROOT / "D03_PlanAnalysis"
|
||||
defs = (folder / "D03_PlanAnalysis_UI_LayerDefs.ts").read_text("utf-8")
|
||||
icons = (ROOT / "ui_template" / "ui_template_icons.ts").read_text("utf-8")
|
||||
theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8")
|
||||
page = (folder / "D03_PlanAnalysis_UI_Layers.ts").read_text("utf-8")
|
||||
legend = (folder / "D03_PlanAnalysis_UI_LayerLegend.ts").read_text("utf-8")
|
||||
for key, color in (
|
||||
("forestRoadFile", "forest-road-file"),
|
||||
("landslideZones", "landslide-zone"),
|
||||
):
|
||||
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), key
|
||||
assert f"--draw-map-{color}:" in theme and f'"--draw-map-{color}"' in page
|
||||
assert re.search(r"^\s+upload:", icons, re.M) and "pickFiles(" in page # 부품 상자 고르기 창
|
||||
assert "<input" not in page and 'createElement("input")' not in page
|
||||
assert page.count("base: true,") == page.count("draw: (context, map) =>") # 겹 모두 바탕
|
||||
assert "임도 짐작" in legend and "올린 임도망도" in legend # 범례 구분
|
||||
assert "이 시도 자료 없음" in legend
|
||||
for name in ("D03_PlanAnalysis_UI_Layers.ts", "D03_PlanAnalysis_UI_LayerLegend.ts"):
|
||||
assert len((folder / name).read_text("utf-8").splitlines()) <= 700
|
||||
@@ -6,6 +6,7 @@
|
||||
* 상태 = 기본 · 마우스(파일을 끌어 옴 = .is-over) · 막힘(`disabled`) · 오류(.is-error)
|
||||
* 누름 = 칸 어디든 누름 · 칸에 초점 두고 Enter · Space = 파일 고르기(옛 B03 놓기 칸 동작) · [파일 고르기] 단추는 같은 일
|
||||
* 쓰는 법 = `const drop = createDrop({ accept: ".las,.laz", onFiles: (files) => upload(files) }); host.append(drop.root)`
|
||||
* 칸 없이 고르기 창만 = `pickFiles({ accept: ".zip", multiple: true }, upload)`(도구줄 아이콘 단추)
|
||||
* 대신하는 옛 변종 = B03 `b03-file__dropzone` · CAD `body.cad-file-dragging::after` · 숨은 file + 단추(B01 · B08 · M02 · cad_host · help_edit)
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -35,6 +36,21 @@ export interface DropHandle {
|
||||
setDisabled: (on: boolean) => void;
|
||||
}
|
||||
|
||||
/** 파일 고르기 창만 띄움(놓기 칸 없이 — 도구줄 아이콘 단추 · 584-3). */
|
||||
export function pickFiles(
|
||||
opts: { accept?: string; multiple?: boolean },
|
||||
onFiles: (files: File[]) => void,
|
||||
): void {
|
||||
const input = el("input", { attrs: { type: "file" } });
|
||||
if (opts.accept) input.accept = opts.accept;
|
||||
input.multiple = !!opts.multiple;
|
||||
input.addEventListener("change", () => {
|
||||
const files = [...(input.files ?? [])];
|
||||
if (files.length) onFiles(files);
|
||||
});
|
||||
input.click();
|
||||
}
|
||||
|
||||
export function createDrop(opts: DropOptions): DropHandle {
|
||||
const input = el("input", { attrs: { type: "file" } });
|
||||
input.hidden = true;
|
||||
|
||||
@@ -148,6 +148,9 @@ const PATHS = {
|
||||
bolt: "M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11",
|
||||
lineDashed: "M5 12h2|M17 12h2|M11 12h2",
|
||||
arrowsJoin: "M3 7h5l3.5 5h9.5|M3 17h5l3.495 -5|M18 15l3 -3l-3 -3",
|
||||
// 584-3 — 지도 겹(산사태취약지역)
|
||||
alertOctagon:
|
||||
"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",
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof PATHS;
|
||||
|
||||
@@ -583,6 +583,8 @@ html.is-bm-route .m01lab-src__body * {
|
||||
--draw-map-fault: #f43f5e; /* 단층 */
|
||||
--draw-map-lineament: #c4b5fd; /* 지질구조선(점선) */
|
||||
--draw-map-zero-order: #7dd3fc; /* 0차곡(지형 추정 골짜기 머리 · 점선) */
|
||||
--draw-map-forest-road-file: #ea580c; /* 올린 임도망도(실선 · 짐작 = 주황 점선) */
|
||||
--draw-map-landslide-zone: #e11d48; /* 산사태취약지역 점 · 필지 테두리 */
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
|
||||
Reference in New Issue
Block a user