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
+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}