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:
2026-10-29 17:27:45 +09:00
co-authored by Claude Opus 5.5
parent 9abd5deb46
commit 5db9cb49f1
16 changed files with 14434 additions and 166 deletions
+53
View File
@@ -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
+61 -3
View File
@@ -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],
]
: [];
}),
),
});
+107 -162
View File
@@ -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
1 # 이름: 광주광역시 산사태취약지역현황('26년 6월기준)
2 # 받은 날: 2026-10-29
3 # 기준일: 2026-06-02
4 # 출처: https://www.data.go.kr/data/15012196/fileData.do
5 # 이용 조건: 공공데이터포털 이용허락범위 제한 없음
6 관할,조사지(시도),조사지(시군구),조사지(동),지번,위도(도),위도(분),위도(초),경도(도),경도(분),경도(초),취약지역유형,지정면적(제곱미터),거리(미터),데이터기준일자
7 동구,광주광역시,동구,운림동,산269임,35,7,41.799,126,57,1,토석류,6368,900,2026-06-02
8 동구,광주광역시,동구,지산동,산60-1임,35,8,59.499,126,57,6,토석류,6227,100,2026-06-02
9 광산구,광주광역시,광산구,광산동,산 85 임,35,14,57.61,126,44,41.963,산사태,441.1,0,2026-06-02
10 광산구,광주광역시,광산구,광산동,산153-1임,35,13,50.6,126,44,28.6,산사태,3412.5,0,2026-06-02
11 광산구,광주광역시,광산구,임곡동,산88임,35,12,40.899,126,44,38.7,토석류,4949,60,2026-06-02
12 광산구,광주광역시,광산구,임곡동,산85임,35,12,44.299,126,44,27.1,토석류,6915,60,2026-06-02
13 광산구,광주광역시,광산구,명도동,산67임,35,10,17.899,126,41,36.2,토석류,4150,131,2026-06-02
14 광산구,광주광역시,광산구,명도동,산59임,35,10,25.472,126,41,49.731,산사태,5708,0,2026-06-02
15 광산구,광주광역시,광산구,명도동,산62임,35,10,20.587,126,41,47.113,산사태,1272,0,2026-06-02
16 광산구,광주광역시,광산구,양산동,414전,35,11,59.546,126,41,15.011,토석류,4180,0,2026-06-02
17 광산구,광주광역시,광산구,산월동,산64-1임,35,11,53.356,126,50,55.016,산사태,306,0,2026-06-02
18 광산구,광주광역시,광산구,박호동,산162임,35,9,50.799,126,44,27.8,토석류,6330,0,2026-06-02
19 광산구,광주광역시,광산구,산월동,산34-3임,35,12,19.36,126,50,51.297,산사태,60.21,0,2026-06-02
20 광산구,광주광역시,광산구,지평동,산57-1임,35,8,59.2,126,43,17.388,토석류,3796,100,2026-06-02
21 동구,광주광역시,동구,월남동,산29임,35,6,12.699,126,56,36,토석류,2249,100,2026-06-02
22 북구,광주광역시,북구,효령동,880-10구,35,14,18.555,126,54,39.057,토석류,1239.52,220,2026-06-02
23 북구,광주광역시,북구,충효동,산24임,35,10,19.449,127,0,56.583,토석류,1080,0,2026-06-02
24 북구,광주광역시,북구,각화동,산71임,35,18,42.32,126,94,39.35,토석류,900,0,2026-06-02
25 북구,광주광역시,북구,금곡동,994답,35,10,3.12,126,58,39.729,토석류,1235.4,0,2026-06-02
26 광산구,광주광역시,광산구,사호동,산112-1임,35,12,49.236,126,43,46.723,산사태,161,0,2026-06-02
27 북구,광주광역시,북구,월출동,산2임,35,14,52.8,126,52,22,산사태,211.65616,0,2026-06-02
28 남구,광주광역시,남구,주월동,450-91대,35,7,34.499,126,53,30.6,토석류,1416,0,2026-06-02
29 남구,광주광역시,남구,송하동,산89-2임,35,6,41.399,126,52,58.9,토석류,2204.8,0,2026-06-02
30 남구,광주광역시,남구,방림동,산16-1임,35,7,47.599,126,55,20.2,토석류,638.4,33,2026-06-02
31 동구,광주광역시,동구,용산동,98-3유,35,6,43.799,126,55,55.8,산사태,1248,30,2026-06-02
32 동구,광주광역시,동구,산수동,산52-1임,35,9,26.818,126,56,32.991,토석류,65346,500,2026-06-02
33 동구,광주광역시,동구,지산동,산56-1임,35,9,0.999,126,57,4.6,토석류,703,100,2026-06-02
34 동구,광주광역시,동구,소태동,산102임,35,6,55.799,126,56,59.5,토석류,1248,450,2026-06-02
35 동구,광주광역시,동구,운림동,196종,35,8,16.699,126,57,57,토석류,1206,85,2026-06-02
36 동구,광주광역시,동구,용산동,산1-2,35,7,32.178,126,55,46.029,산사태,583,300,2026-06-02
37 광산구,광주광역시,광산구,광산동,산 135 임,35,14,6.899,126,44,45.1,토석류,2808.4,0,2026-06-02
38 광산구,광주광역시,광산구,두정동,578구,35,13,43.4,126,45,42.542,토석류,2368,0,2026-06-02
39 광산구,광주광역시,광산구,사호동,산136임,35,12,55.699,126,44,11.2,토석류,4833,0,2026-06-02
40 광산구,광주광역시,광산구,사호동,산107-2임,35,12,46.099,126,43,37,토석류,1248,0,2026-06-02
41 광산구,광주광역시,광산구,왕동,산55임,35,12,15.53,126,42,43.672,산사태,6333.2,0,2026-06-02
42 광산구,광주광역시,광산구,북산동,산50-2임,35,10,39.392,126,42,38.367,산사태,1968.3,0,2026-06-02
43 광산구,광주광역시,광산구,내산동,산241임,35,8,6.198,126,39,28.156,산사태,8064.42,0,2026-06-02
44 광산구,광주광역시,광산구,대산동,산67-1임,35,8,36.407,126,40,7.875,산사태,398.46,0,2026-06-02
45 광산구,광주광역시,광산구,동호동,산19-1,35,35,56.459,128,6,38.029,토석류,142.09,0,2026-06-02
46 남구,광주광역시,남구,임암동,323종,35,6,26.199,126,52,20.7,토석류,2158.4,0,2026-06-02
47 남구,광주광역시,남구,진월동,산234-1임,35,7,19.899,126,53,33.5,토석류,1666.6667,0,2026-06-02
48 북구,광주광역시,북구,두암동,산82임,35,10,13.099,126,56,31.1,토석류,1532,20,2026-06-02
49 북구,광주광역시,북구,화암동,산97임,35,9,20.804,126,57,30.225,토석류,795.6,120,2026-06-02
50 북구,광주광역시,북구,청풍동,산314임,35,17,8.69,126,95,81.66,토석류,757,0,2026-06-02
51 북구,광주광역시,북구,망월동,산14임,35,12,32.199,126,57,9.9,토석류,623.3333,50,2026-06-02
52 북구,광주광역시,북구,두암동,200답,35,10,8.599,126,56,28.6,토석류,88.07,50,2026-06-02
53 북구,광주광역시,북구,양산동,산99-1임,35,11,35.756,126,52,33.819,산사태,0,0,2026-06-02
54 광산구,광주광역시,광산구,서봉동,산294-2,35,9,9.606,126,46,1.455,토석류,1518,0,2026-06-02
55 광산구,광주광역시,광산구,산막동,산61임,35,11,34.111,126,45,32.951,산사태,480.58,0,2026-06-02
56 북구,광주광역시,북구,생용동,산89임,35,22,31.54,126,90,52.56,토석류,815,0,2026-06-02
57 북구,광주광역시,북구,청풍동,산245임,35,10,36.8,126,57,31.4,산사태,277.07968,0,2026-06-02
58 광산구,광주광역시,광산구,임곡동,산87-1임,35,12,49,126,44,34.5,산사태,185.26,0,2026-06-02
59 북구,광주광역시,북구,문흥동,산16임,35,11,47.2,126,56,13.6,토석류,716.21,0,2026-06-02
60 남구,광주광역시,남구,진월동,산206-1임,35,6,58.599,126,53,28.1,토석류,440,194,2026-06-02
61 남구,광주광역시,남구,송하동,541-24구,35,6,58.199,126,52,29.3,토석류,823.3333,95,2026-06-02
62 남구,광주광역시,남구,석정동,산23-1,35,5,16.48,126,49,49,산사태,177,28,2026-06-02
63 동구,광주광역시,동구,월남동,산130임,35,6,10.899,126,56,5.7,토석류,2325,100,2026-06-02
64 동구,광주광역시,동구,소태동,산275-1임,35,7,43.2,126,56,56,토석류,146.78,100,2026-06-02
65 동구,광주광역시,동구,선교동,11전,35,5,32.499,126,57,25.3,토석류,1424,100,2026-06-02
66 동구,광주광역시,동구,월남동,609-2대,35,6,18.88,126,55,33.095,토석류,3836,700,2026-06-02
67 북구,광주광역시,북구,망월동,산108임,35,11,15.029,126,57,17.12,토석류,22711,0,2026-06-02
68 광산구,광주광역시,광산구,내산동,산 168 임,35,8,5.699,126,39,25.9,토석류,1804,0,2026-06-02
69 북구,광주광역시,북구,청풍동,산256임,35,10,28.286,126,57,35.111,산사태,0,0,2026-06-02
70 광산구,광주광역시,광산구,광산동,산29임,35,15,5.7,126,44,44.1,산사태,1651.6,0,2026-06-02
71 광산구,광주광역시,광산구,등임동,산96번지,35,10,49.159,126,45,44.236,토석류,3286,0,2026-06-02
72 광산구,광주광역시,광산구,두정동,산48임,35,13,39.699,126,45,29.9,토석류,2937.6,100,2026-06-02
73 광산구,광주광역시,광산구,북산동,3-2임,35,11,12.882,126,42,47.093,토석류,164,50,2026-06-02
74 북구,광주광역시,북구,장등동,산3-1임,35,12,44.9,126,56,28.1,산사태,0,0,2026-06-02
75 북구,광주광역시,북구,화암동,산95임,35,9,22.599,126,57,27.8,토석류,807.24,65,2026-06-02
76 북구,광주광역시,북구,망월동,산 17 임,35,12,25.922,126,57,14.606,토석류,420,10,2026-06-02
77 북구,광주광역시,북구,각화동,산20-3임,35,11,30.099,126,56,22.1,토석류,915,30,2026-06-02
78 광산구,광주광역시,광산구,임곡동,산81,35,12,48.96,126,44,24.909,토석류,2131,0,2026-06-02
79 광산구,광주광역시,광산구,선동,산12-2임,35,12,13.8,126,45,10.099,산사태,2715.08,0,2026-06-02
80 광산구,광주광역시,광산구,서봉동,산84-1임,35,9,17.5,126,44,55.6,산사태,298.26,0,2026-06-02
81 동구,광주광역시,동구,용연동,산53임,35,5,40.8,126,57,57,산사태,105.63,100,2026-06-02
82 광산구,광주광역시,광산구,동호동,산30임,35,10,57.312,126,41,33.1,산사태,456,0,2026-06-02
83 동구,광주광역시,동구,운림동,산256임,35,7,29.3,126,57,57,토석류,129.74,100,2026-06-02
84 남구,광주광역시,남구,주월동,산198임,35,7,32.199,126,54,7.6,토석류,3492,150,2026-06-02
85 남구,광주광역시,남구,방림동,41-5 도,35,7,44.799,126,55,26.3,토석류,2866,0,2026-06-02
86 남구,광주광역시,남구,주월동,산169임,35,7,26.499,126,53,33.2,토석류,1896,0,2026-06-02
87 남구,광주광역시,남구,덕남동,산 80번지,35,5,9.24,126,54,54,산사태,353,22,2026-06-02
88 광산구,광주광역시,광산구,우산동,산101,35,9,13.89,126,48,24.23,산사태,200,10,2026-06-02
89 동구,광주광역시,동구,선교동,472-19천,35,5,29.457,126,57,12.094,토석류,7343,60,2026-06-02
90 동구,광주광역시,동구,운림동,산 143-1 임,35,7,43.599,126,58,13.8,토석류,6614,300,2026-06-02
91 동구,광주광역시,동구,지산동,산24-3임,35,9,0.482,126,56,34.417,산사태,249,100,2026-06-02
92 동구,광주광역시,동구,소태동,산238-1임,35,6,57.406,126,56,55.999,토석류,6705,100,2026-06-02
93 동구,광주광역시,동구,운림동,198전,35,8,19.099,126,57,8.4,토석류,1206,90,2026-06-02
94 동구,광주광역시,동구,운림동,산267임,35,7,40.299,126,57,7.8,토석류,8131,900,2026-06-02
95 동구,광주광역시,동구,월남동,456대,35,6,19.063,126,55,35.315,토석류,5345,700,2026-06-02
96 동구,광주광역시,동구,내남동,878도,35,5,50.499,126,55,53.8,토석류,47375,30,2026-06-02
97 동구,광주광역시,동구,내남동,산122임,35,4,55.956,126,55,42.807,산사태,248,1400,2026-06-02
98 광산구,광주광역시,광산구,광산동,산 135 임,35,14,17.799,126,44,39.2,토석류,4554,50,2026-06-02
99 광산구,광주광역시,광산구,광산동,산87-2임,35,14,53.4,126,44,38.5,산사태,248.9,0,2026-06-02
100 광산구,광주광역시,광산구,선동,산100-1임,35,11,52.5,126,44,24.9,산사태,1140.66,0,2026-06-02
101 광산구,광주광역시,광산구,오산동,산4임,35,13,59.4,126,43,40.5,산사태,495.08,0,2026-06-02
102 광산구,광주광역시,광산구,왕동,산100-3임,35,11,55.607,126,43,29.295,토석류,1533.6173,0,2026-06-02
103 광산구,광주광역시,광산구,북산동,산48-1임,35,10,39.073,126,42,40.313,산사태,986.64,0,2026-06-02
104 광산구,광주광역시,광산구,박호동,산152임,35,9,59.199,126,44,32.3,토석류,6662,0,2026-06-02
105 광산구,광주광역시,광산구,박호동,산156-1임,35,9,53.879,126,44,31.411,토석류,5356,25,2026-06-02
106 광산구,광주광역시,광산구,임곡동,산102-3,35,12,38.761,126,44,42.524,산사태,241,0,2026-06-02
107 북구,광주광역시,북구,각화동,산71임,35,11,3.699,126,56,38.5,토석류,0,0,2026-06-02
108 광산구,광주광역시,광산구,지평동,산37-4임,35,9,7.199,126,44,23.2,산사태,3940,0,2026-06-02
109 북구,광주광역시,북구,화암동,397-1전,35,9,31.399,126,57,45.4,토석류,844,400,2026-06-02
110 북구,광주광역시,북구,두암동,산95-2임,35,10,8.263,126,56,25.757,산사태,0,0,2026-06-02
111 북구,광주광역시,북구,신안동,310-6대,35,10,22.5,126,53,44.626,토석류,350,0,2026-06-02
112 북구,광주광역시,북구,장등동,산287임,35,12,5.499,126,56,9.3,토석류,0,0,2026-06-02
113 광산구,광주광역시,광산구,동호동,산54-1임,35,11,12.3,126,41,1.399,산사태,78,0,2026-06-02
114 북구,광주광역시,북구,두암동,산98-1임,35,10,8.2,126,56,29.9,산사태,237.71153,0,2026-06-02
115 광산구,광주광역시,광산구,광산동,산29임,35,35,5.052,128,6,47.818,산사태,344.82,0,2026-06-02
116 광산구,광주광역시,광산구,내산동,산33-1임,35,13,30.56,126,66,52.88,토석류,3732,0,2026-06-02
117 광산구,광주광역시,광산구,명도동,산49-1임,35,17,92.99,126,69,89.77,토석류,4252,0,2026-06-02
118 광산구,광주광역시,광산구,명도동,산58임,35,17,70.78,126,69,76.35,토석류,2782,0,2026-06-02
119 광산구,광주광역시,광산구,산월동,산36임,35,20,59.65,126,84,84.86,산사태,408,0,2026-06-02
120 광산구,광주광역시,광산구,선암동,산15-6임,35,15,61.24,126,77,4.24,토석류,2613,0,2026-06-02
121 광산구,광주광역시,광산구,오산동,산33임,35,22,39.87,126,72,37.31,산사태,203,0,2026-06-02
122 광산구,광주광역시,광산구,임곡동,산33-2임,35,22,9.89,126,75,10.55,토석류,550,0,2026-06-02
123 광산구,광주광역시,광산구,임곡동,산31-2임,35,21,77.22,126,75,15,산사태,104,0,2026-06-02
124 광산구,광주광역시,광산구,명도동,산67-4임,35,17,11.58,126,69,47.22,산사태,1384,0,2026-06-02
125 광산구,광주광역시,광산구,광산동,산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
+2
View File
@@ -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
+16
View File
@@ -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;
+3
View File
@@ -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;
+2
View File
@@ -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"] {