From 40cecf27a2c725e9aed1381f1100b3c307cffd29 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 21:49:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8=EC=84=A0):?= =?UTF-8?q?=20=EC=98=AC=EB=A6=B0=20=EC=9E=84=EB=8F=84=EB=A7=9D=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=EC=8C=93=EA=B8=B0=20=C2=B7=20=EB=AA=A9=EB=A1=9D(?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=C2=B7=20=EC=A7=80=EC=9B=80)=20?= =?UTF-8?q?=C2=B7=20=EC=A7=80=EB=8F=84=20=EB=B0=9D=EC=9D=80=20=EB=B6=84?= =?UTF-8?q?=ED=99=8D=20+=20=EA=B2=80=EC=9D=80=20=ED=85=8C=20(PLAN=20605-1?= =?UTF-8?q?=20=C2=B7=20605-2=20=C2=B7=20=EC=9A=94=EA=B5=AC=20476-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 — 올릴 때마다 묶음 하나(`D03_PlanAnalysis/임도망도/<번호>`) · 옛 한 파일 꼴 = 묶음 legacy · 합친 값 + 묶음 줄 · 목록 GET · 묶음 DELETE · 20 묶음까지 - 화면 — 위치 칸 목록(묶음 줄 · 펼치면 선 줄 · 고름 = 지도 강조 + 맞춤 · 지움 = 물음 뒤) · 색 주황 → 밝은 분홍(`--draw-map-forest-road-file`) - 시험 584_3 — 묶음 쌓기 · 지움 · legacy · 20 묶음 · 화면 정의 Co-Authored-By: Claude Sonnet 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 37 +++- .../D03_PlanAnalysis_ForestRoad.py | 163 ++++++++++++--- D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 34 +++- .../D03_PlanAnalysis_UI_LayerDefs.ts | 9 +- .../D03_PlanAnalysis_UI_LayerPaint.ts | 9 +- .../D03_PlanAnalysis_UI_Layers.ts | 89 ++++++++- D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts | 40 +++- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 10 +- .../D03_PlanAnalysis_UI_RoadList.ts | 186 ++++++++++++++++++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts | 17 +- .../test_584_3_plan_forest_road_zones.py | 86 +++++++- ui_template/ui_template_theme.css | 2 +- 12 files changed, 629 insertions(+), 53 deletions(-) create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_RoadList.ts diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index d353e00ba..5c28a90e6 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -251,9 +251,25 @@ export const fetchPlanZeroOrder = (projectId: string): Promise => export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> => request(`${base(projectId)}/layers/bands`); -/** 올린 임도망도(584-3 · 595-4) — 기준점 ±5 km ∪ 받은 지형으로 자른 선 + 가닥 · 길이 m · 원본 선 수 · - * 좌표계 · 잘린 상자([서, 남, 동, 북] 경위도) · 기준점 ~ 가장 가까운 임도 m · 올린 파일. */ +/** 올린 묶음 한 줄(605-1) — 올릴 때마다 하나 · 자른 가닥 · 길이 m · 원본 선 수 · 잘린 상자 · 기준점 거리. */ +export interface PlanRoadBundle { + id: string; + name: string; + files: string[]; + bytes: number | null; + uploaded_at: string | null; + count: number; + length_m: number; + total: number; + bounds: [number, number, number, number] | null; + distance_m: number | null; +} + +/** 올린 임도망도(584-3 · 595-4 · 605-1) — 묶음 모두를 합친 값: 기준점 ±5 km ∪ 받은 지형으로 자른 선 + * (선마다 `__bundle` 묶음 번호 · `__line` 묶음 안 차례) + 가닥 · 길이 m · 원본 선 수 · 좌표계 · + * 잘린 상자([서, 남, 동, 북] 경위도) · 기준점 ~ 가장 가까운 임도 m · 마지막 올린 파일 · 묶음 줄. */ export interface PlanForestRoads { + bundles: PlanRoadBundle[]; count: number; length_m: number; total: number; @@ -268,6 +284,23 @@ export interface PlanForestRoads { export const fetchPlanForestRoads = (projectId: string): Promise => request(`${base(projectId)}/layers/forest-roads`); +/** 묶음 하나 지움(605-1) — 남은 묶음 합친 값 · 하나도 안 남으면 null. */ +export async function deletePlanForestRoad( + projectId: string, + bundle: string, +): Promise { + const response = await fetch( + `${API_BASE_URL}${base(projectId)}/layers/forest-roads/${encodeURIComponent(bundle)}`, + { method: "DELETE", credentials: "include" }, + ); + 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.bundles.length ? body : null; +} + /** 임도망도 SHP 올리기(zip 하나 · shp · shx · dbf · prj 묶음) — 못 읽으면 서버 글 그대로 오류. */ export async function uploadPlanForestRoads( projectId: string, diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py b/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py index d18a2b9bd..24d8440a4 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py @@ -4,8 +4,9 @@ - 올림 = zip 하나 또는 shp · shx · dbf(· prj · cpg) 묶음 · 합계 `MAX_BYTES` · 읽어 보고 자르는 범위 안 선이 있어야 받음(아니면 옛 것 그대로 · 까닭 = `ValueError` + 기준점 ~ 가장 가까운 임도 거리) - 자르는 범위 = 기준점 ± `RADIUS_M` ∪ 받은 지형(`clip_box` · 지형 받기 전에도 기준점만으로) -- 원본 = 프로젝트 `D03_PlanAnalysis/임도망도.zip`(낱 파일은 zip 으로 묶음) + - `임도망도.json`(파일 이름 · 올린 때) +- 원본 = 올릴 때마다 묶음 하나 — 프로젝트 `D03_PlanAnalysis/임도망도/<번호>.zip`(낱 파일은 zip 으로 + 묶음) + `<번호>.json`(파일 이름 · 올린 때) · 옛 한 파일 꼴(`임도망도.zip` · `.json`)은 묶음 + `legacy` 하나로 읽음 · 지움 = 그 묶음만(PLAN 605-1) - 좌표계 = prj · 없으면 EPSG:5179 · dbf 글자 = cpg · 없으면 cp949 · 자르는 범위로 잘라 가닥 · 길이 · 잘린 상자(`bounds` 경위도) · 기준점 거리 → 파생 저장본(열쇠 = 원본 지문 · 범위 · 기준점) @@ -18,7 +19,9 @@ import io import json import math import os +import re import tempfile +import uuid import zipfile from pathlib import Path from typing import Any @@ -36,14 +39,18 @@ Box = tuple[float, float, float, float] XY = tuple[float, float] FOLDER = "D03_PlanAnalysis" -SOURCE_ZIP = "임도망도.zip" +SOURCE_ZIP = "임도망도.zip" # 옛 한 파일 꼴 = 묶음 legacy SOURCE_META = "임도망도.json" +BUNDLE_DIR = "임도망도" +LEGACY = "legacy" +MAX_BUNDLES = 20 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 = 2 # 잘린 상자 · 기준점 거리 +ROAD_VERSION = 3 # 묶음 열쇠 +KEEP = 2 * MAX_BUNDLES # 묶음마다 파생 저장본 한 벌 RADIUS_M = 5000.0 # 기준점 둘레(요구서_463 3장 18) @@ -169,29 +176,117 @@ def clip_roads(zip_path: Path, box: Box, epsg: int, origin: XY | None = None) -> } -def road_layer(project_root: Path, box: Box, epsg: int, origin: XY | None = None) -> dict[str, Any]: - """올린 임도망도 — 원본 지문 · 범위 · 기준점 같으면 저장본 · 올린 적 없으면 - `FileNotFoundError`.""" - path = source_dir(project_root) / SOURCE_ZIP +def _paths(project_root: Path, bundle: str) -> tuple[Path, Path]: + """(zip, 이름표 json) — `legacy` = 옛 한 파일 꼴.""" + folder = source_dir(project_root) + if bundle == LEGACY: + return folder / SOURCE_ZIP, folder / SOURCE_META + return folder / BUNDLE_DIR / f"{bundle}.zip", folder / BUNDLE_DIR / f"{bundle}.json" + + +def _meta(path: Path) -> dict[str, Any]: + try: + return json.loads(path.read_text("utf-8")) + except (OSError, ValueError): + return {} + + +def _name(files: list[str]) -> str: + """묶음 줄 이름 — 파일 하나(zip) = 그 이름 · 낱 파일 묶음 = 첫 파일 줄기.""" + return "" if not files else files[0] if len(files) == 1 else Path(files[0]).stem + + +def bundle_ids(project_root: Path) -> list[str]: + """올린 순서(올린 때) — 옛 한 파일 꼴 포함.""" + folder = source_dir(project_root) + ids = [p.stem for p in (folder / BUNDLE_DIR).glob("*.zip")] + if (folder / SOURCE_ZIP).is_file(): + ids.append(LEGACY) + + def when(bundle: str) -> tuple[str, str]: + zip_path, meta_path = _paths(project_root, bundle) + return _meta(meta_path).get("uploaded_at", ""), file_stamp(zip_path) or "" + + return sorted(ids, key=when) # 같은 초면 파일 시각 순 + + +def bundle_layer( + project_root: Path, bundle: str, box: Box, epsg: int, origin: XY | None = None +) -> dict[str, Any]: + """묶음 하나 — 원본 지문 · 범위 · 기준점 같으면 저장본 · 없으면 `FileNotFoundError`.""" + path, meta = _paths(project_root, bundle) stamp = file_stamp(path) if stamp is None: - raise FileNotFoundError( - "올린 임도망도가 없습니다 — [임도망 올리기]로 시군구 SHP 를 올리세요." - ) + raise FileNotFoundError(f"올린 임도망도 묶음 {bundle} 이(가) 없습니다.") at = [round(v, 1) for v in origin] if origin else None - key = derived_key(ROAD_KIND, ROAD_VERSION, stamp, int(epsg), [round(v, 1) for v in box], at) + key = derived_key( + ROAD_KIND, ROAD_VERSION, bundle, stamp, int(epsg), [round(v, 1) for v in box], at + ) saved = read_derived(project_root, ROAD_KIND, key) if saved is not None: return saved["value"] value = clip_roads(path, box, epsg, origin) - 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) + value["source"] = _meta(meta) or None + write_derived(project_root, ROAD_KIND, key, value, KEEP) return value +def road_layer(project_root: Path, box: Box, epsg: int, origin: XY | None = None) -> dict[str, Any]: + """올린 임도망도 전체 — 묶음마다 자른 값을 합침(선 `__bundle` = 묶음 번호 · + `__line` = 묶음 안 차례 · `bundles` = 묶음 줄 · 가닥 · 길이 · 원본 선 수 = 합 · + 기준점 거리 = 가장 가까운 것) · 올린 적 없으면 `FileNotFoundError`.""" + ids = bundle_ids(project_root) + if not ids: + raise FileNotFoundError( + "올린 임도망도가 없습니다 — [임도망 올리기]로 시군구 SHP 를 올리세요." + ) + rows, features, crs, edges, gaps = [], [], set(), [], [] + for bundle in ids: + got = bundle_layer(project_root, bundle, box, epsg, origin) + source = got.get("source") or {} + files = source.get("files") or [] + rows.append( + { + "id": bundle, + "name": source.get("name") or _name(files) or bundle, + "files": files, + "bytes": source.get("bytes"), + "uploaded_at": source.get("uploaded_at"), + "count": got["count"], + "length_m": got["length_m"], + "total": got["total"], + "bounds": got["bounds"], + "distance_m": got["distance_m"], + } + ) + crs.update(got["crs"]) + if got["bounds"]: + edges.append(got["bounds"]) + if got["distance_m"] is not None: + gaps.append(got["distance_m"]) + for line, feature in enumerate(got["collection"]["features"]): + props = {**feature["properties"], "__bundle": bundle, "__line": line} + features.append({**feature, "properties": props}) + return { + "count": sum(r["count"] for r in rows), + "length_m": round(sum(r["length_m"] for r in rows), 1), + "total": sum(r["total"] for r in rows), + "crs": sorted(crs), + "bounds": [ + min(e[0] for e in edges), + min(e[1] for e in edges), + max(e[2] for e in edges), + max(e[3] for e in edges), + ] + if edges + else None, + "distance_m": min(gaps) if gaps else None, + "source": {k: rows[-1][k] for k in ("files", "bytes", "uploaded_at")}, + "bundles": rows, + "collection": {"type": "FeatureCollection", "features": features}, + } + + def save_roads( project_root: Path, files: list[tuple[str, bytes]], @@ -199,12 +294,17 @@ def save_roads( epsg: int, origin: XY | None = None, ) -> dict[str, Any]: - """올림 — 묶어 읽어 보고 자르는 범위 안 선이 있으면 원본을 바꿈 · 아니면 `ValueError` - (옛 것 그대로 · 기준점 ~ 가장 가까운 임도 거리).""" + """올림 — 묶어 읽어 보고 자르는 범위 안 선이 있으면 새 묶음으로 쌓음 · 아니면 `ValueError` + (쌓인 것 그대로 · 기준점 ~ 가장 가까운 임도 거리).""" + if len(bundle_ids(project_root)) >= MAX_BUNDLES: + raise ValueError(f"올린 묶음이 {MAX_BUNDLES} 개입니다 — 하나를 지우고 올리세요.") 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: + bundle = uuid.uuid4().hex[:12] + zip_path, meta_path = _paths(project_root, bundle) + zip_path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + dir=zip_path.parent, suffix=".zip.new", delete=False + ) as temporary: temporary.write(data) trial = Path(temporary.name) try: @@ -216,15 +316,28 @@ def save_roads( f"임도가 기준점 ±{RADIUS_M / 1000:.0f} km · 받은 지형 밖뿐입니다{where}" f"(파일 선 {got['total']}가닥) — 사업지 시군구 파일인지 보세요." ) - os.replace(trial, folder / SOURCE_ZIP) + os.replace(trial, zip_path) finally: trial.unlink(missing_ok=True) + names = [Path(name).name for name, _ in files] atomic_write_json( - folder / SOURCE_META, + meta_path, { - "files": [Path(name).name for name, _ in files], + "files": names, + "name": _name(names), "bytes": len(data), "uploaded_at": utc_now_text(), }, ) return road_layer(project_root, box, epsg, origin) + + +def delete_bundle(project_root: Path, bundle: str) -> None: + """묶음 하나 지움(저장본은 열쇠가 달라져 저절로 안 쓰임) — 없으면 `FileNotFoundError`.""" + if bundle != LEGACY and not re.fullmatch(r"[0-9a-f]{12}", bundle): + raise FileNotFoundError(f"올린 임도망도 묶음 {bundle} 이(가) 없습니다.") + zip_path, meta_path = _paths(project_root, bundle) + if not zip_path.is_file(): + raise FileNotFoundError(f"올린 임도망도 묶음 {bundle} 이(가) 없습니다.") + zip_path.unlink() + meta_path.unlink(missing_ok=True) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index e1e7f351a..13a7fb75d 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -6,8 +6,8 @@ - `GET …/plan/tile/{z}/{x}/{y}` — 위성 조각 대리 · 서버 기억(586 GL 바탕) - `GET …/plan/layers/sheet?key=` · `…/cadastral` · `…/raster?kind=slope|hillshade|landslide` · `…/zones` · `…/soils` · `…/bands` · `…/lines?kind=` · `…/zero-order` · - `…/forest-roads`(GET · POST 올리기) · `…/landslide-zones` — 지도 겹 - (PLAN 569-1 · 581-1 · 581-2 · 584-1 · 584-3) + `…/forest-roads`(GET · POST 올리기 · `/bundles` 목록 · DELETE `/<번호>`) · + `…/landslide-zones` — 지도 겹 (PLAN 569-1 · 581-1 · 581-2 · 584-1 · 584-3 · 605-1) 범위 = 기준점 둘레(`SITE_RADIUS_M`) + 지점 + 그은 점(`extra` · 지도 보기 귀) 상자 + 여유 ∪ 받은 범위 (PLAN 591-2 · 기준점 = 처음 받는 기준일 뿐). @@ -432,8 +432,8 @@ async def _road_frame(project_id: UUID) -> tuple[Path, int, terrain.Box, tuple[f @router.get("/{project_id}/plan/layers/forest-roads", response_model=None) async def plan_layer_forest_roads(project_id: UUID) -> dict[str, Any]: - """올린 임도망도(기준점 ± 5 km ∪ 받은 지형으로 자른 선 · 가닥 · 길이 · 잘린 상자 · 기준점 - 거리) — 올린 적 없으면 404.""" + """올린 임도망도(묶음마다 기준점 ± 5 km ∪ 받은 지형으로 자른 선 · 합친 가닥 · 길이 · 잘린 상자 · + 기준점 거리 · `bundles` 묶음 줄) — 올린 적 없으면 404.""" root, epsg, box, origin = await _road_frame(project_id) try: value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg, origin) @@ -442,6 +442,32 @@ async def plan_layer_forest_roads(project_id: UUID) -> dict[str, Any]: return {"status": "success", **value} +@router.get("/{project_id}/plan/layers/forest-roads/bundles", response_model=None) +async def plan_layer_forest_road_bundles(project_id: UUID) -> dict[str, Any]: + """올린 묶음 목록(선 없이 · 올린 순서) — 올린 적 없으면 빈 목록.""" + root, epsg, box, origin = await _road_frame(project_id) + try: + value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg, origin) + except FileNotFoundError: + return {"status": "success", "bundles": []} + return {"status": "success", "bundles": value["bundles"]} + + +@router.delete("/{project_id}/plan/layers/forest-roads/{bundle}", response_model=None) +async def plan_layer_forest_road_delete(project_id: UUID, bundle: str) -> dict[str, Any]: + """올린 묶음 하나 지움 — 남은 묶음 합친 값(남은 것 없으면 `bundles` 빈 목록) · 없으면 404.""" + root, epsg, box, origin = await _road_frame(project_id) + try: + await asyncio.to_thread(forest_road.delete_bundle, root, bundle) + except FileNotFoundError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + try: + value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg, origin) + except FileNotFoundError: + return {"status": "success", "bundles": []} + 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(...) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts index eb9f09827..139f06f3f 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts @@ -42,6 +42,12 @@ export type PlanLayerKey = | "building" | "place"; +/** 올린 임도망 고름(605-2) — 묶음 하나 또는 그 안의 선 하나(지도 강조). */ +export interface RoadPick { + bundle: string; + line?: number; +} + export interface LayerDef { key: PlanLayerKey; icon: IconName; @@ -70,7 +76,8 @@ export const GROUPS: readonly (readonly LayerDef[])[] = [ { key: "forestRoadFile", icon: "map", - title: "올린 임도망도(산림청 시군구 SHP = 진한 주황 실선 · 왼쪽 「위치」 칸에서 올림)", + title: + "올린 임도망도(산림청 시군구 SHP = 밝은 분홍 실선 · 왼쪽 「위치」 칸에서 올리고 묶음 고름 · 지움)", on: false, }, ], diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerPaint.ts index ae75465a7..c98d52fe9 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerPaint.ts @@ -12,6 +12,7 @@ import { drawPreparedLabels, lonLatToScreen, pickLevelStep, + type PreparedLayer, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { LINE_KEYS, @@ -30,6 +31,8 @@ export function createCanvasLayers(opts: { on: Record; data: Partial; meta: () => VWorldMeta | null; + /** 고른 임도망 묶음 · 선(605-2) — 없으면 null. */ + roadPick: () => PreparedLayer | null; }): PlanMapLayer[] { const { on, data } = opts; /** 등고 글 폭 기억(글꼴 하나 · 582). */ @@ -173,7 +176,11 @@ export function createCanvasLayers(opts: { 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); + stroke(context, map, got.layer, themeColor("--draw-map-forest-road-file", "#ff2fd0"), 2.4); + const pick = opts.roadPick(); + if (!pick) return; + stroke(context, map, pick, "rgba(0, 0, 0, 0.55)", 7.4); + stroke(context, map, pick, themeColor("--draw-map-sketch", "#ffe94d"), 4.2); }, }, { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index ec5041073..76c42bde7 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -52,6 +52,7 @@ import { fetchPlanSoils, fetchPlanZeroOrder, fetchPlanZones, + deletePlanForestRoad, uploadPlanForestRoads, type PlanForestRoads, } from "./D03_PlanAnalysis_Api"; @@ -69,6 +70,7 @@ import { readPref, shadeImage, type PlanLayerKey, + type RoadPick, } from "./D03_PlanAnalysis_UI_LayerDefs"; import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend"; import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint"; @@ -110,6 +112,10 @@ export interface PlanLayers { load: () => Promise; /** 임도망도 올리기(왼쪽 「위치」 칸) — 받으면 그 겹을 켬 · 못 받으면 알림 + null. */ uploadRoads: (files: File[]) => Promise; + /** 올린 묶음 하나 지움(605-1) — 지도에서 바로 빠짐 · 못 지우면 알림 + 거짓. */ + deleteRoad: (bundle: string) => Promise; + /** 올린 묶음 · 선 고름(605-2) — 지도 강조(꺼 있던 겹은 켬) · null = 강조 없앰. */ + pickRoad: (pick: RoadPick | null) => void; /** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */ state: () => { on: Record; @@ -294,11 +300,7 @@ export function createPlanLayers(opts: { const at = meta; const job = uploadPlanForestRoads(projectId, files).then((info) => { if (meta !== at) return info; - if (at) - data.forestRoadFile = { layer: prepareLayer(info.collection, createNormalizer(at)), info }; - gl.setData("forestRoadFile", info.collection); - on.forestRoadFile = true; - writeByKey(PREF_KEY, JSON.stringify(on)); + applyRoads(info); showToast( `임도망도 받음 — 범위 안 ${info.count}가닥 ${(info.length_m / 1000).toFixed(2)} km (파일 ${info.total}가닥)`, "success", @@ -328,6 +330,80 @@ export function createPlanLayers(opts: { } } + /** 올린 임도망 자료를 바꿔 그림(올림 · 지움) — 켜 둠 · null = 하나도 안 남음(끔). */ + function applyRoads(info: PlanForestRoads | null): void { + if (info) { + if (meta) + data.forestRoadFile = { + layer: prepareLayer(info.collection, createNormalizer(meta)), + info, + }; + gl.setData("forestRoadFile", info.collection); + } else { + delete data.forestRoadFile; + gl.setData("forestRoadFile", null); + roadPick = null; + gl.setRoadPick(null); + } + on.forestRoadFile = !!info; + writeByKey(PREF_KEY, JSON.stringify(on)); + } + + async function deleteRoad(bundle: string): Promise { + try { + const info = await deletePlanForestRoad(projectId, bundle); + if (roadPick?.bundle === bundle) pickRoad(null); + applyRoads(info); + showToast( + info + ? `임도망도 묶음 지움 — 남은 ${info.bundles.length}묶음` + : "임도망도 묶음 지움 — 남은 것 없음", + "success", + ); + return true; + } catch (error) { + showToast( + `임도망도 묶음을 지우지 못함 — ${error instanceof Error ? error.message : String(error)}`, + "error", + ); + return false; + } finally { + sync(); + map.redrawBase(); + } + } + + /** 고른 묶음 · 선만 담은 캔버스 자료(옛 그림용) — 자료 · 고름이 같으면 그대로. */ + let roadPick: RoadPick | null = null; + let pickMemo: { from: unknown; pick: RoadPick; layer: PreparedLayer } | null = null; + function pickLayer(): PreparedLayer | null { + const got = data.forestRoadFile; + if (!roadPick || !got || !meta) return null; + if (pickMemo?.from !== got || pickMemo.pick !== roadPick) { + const mine = roadPick; + const features = (got.info.collection.features ?? []).filter((f) => { + const props = f.properties ?? {}; + return ( + props["__bundle"] === mine.bundle && + (mine.line === undefined || props["__line"] === mine.line) + ); + }); + pickMemo = { + from: got, + pick: mine, + layer: prepareLayer({ ...got.info.collection, features }, createNormalizer(meta)), + }; + } + return pickMemo.layer; + } + + function pickRoad(pick: RoadPick | null): void { + roadPick = pick; + gl.setRoadPick(pick); + if (pick && !on.forestRoadFile) toggle("forestRoadFile"); + else map.redrawBase(); + } + /** 받은 범위 지문 — 등고 · 세류 주소에 붙여 브라우저 보관함이 옛 범위를 안 줌. */ const haveTag = (): string => (opts.have() ?? []).map((v) => Math.round(v)).join("_"); const sheetLayer = (layer: string, extra = ""): Promise => @@ -538,6 +614,7 @@ export function createPlanLayers(opts: { on, data, meta: () => meta, + roadPick: pickLayer, }); /** GL 이면 캔버스 바탕 겹은 쉼 — 등고 주곡선 간격(화면 안 가닥 수)만 셈. */ @@ -581,6 +658,8 @@ export function createPlanLayers(opts: { layers, load, uploadRoads, + deleteRoad, + pickRoad, state: () => ({ on: { ...on }, loaded: LAYERS.map((def) => def.key).filter(loadedOf), diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts index 1d9ae8e04..bcdf7b97a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts @@ -23,7 +23,7 @@ import { } from "@ui/ui_template_gl_map"; import { themeColor } from "@ui/ui_template_palette"; import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; -import type { PlanLayerKey } from "./D03_PlanAnalysis_UI_LayerDefs"; +import type { PlanLayerKey, RoadPick } from "./D03_PlanAnalysis_UI_LayerDefs"; import { SAT_LIGHT_DEFAULT } from "./D03_PlanAnalysis_UI_SatLight"; export { GL_FONT, type GlImage }; @@ -39,6 +39,8 @@ export interface GlBase { setVisible: (key: PlanLayerKey, on: boolean) => void; /** 등고선 주곡선 간격(m) — 캔버스와 같은 「화면 안 가닥 수」 규칙. */ setContourStep: (step: number) => void; + /** 올린 임도망 고름(605-2) — 그 묶음 · 선만 굵게 강조 · null = 없앰. */ + setRoadPick: (pick: RoadPick | null) => void; /** 위성 밝기 상한(0~1) — 바로 반영 · 지도를 다시 세워도 유지. */ setSatelliteLight: (level: number) => void; /** 켬 = 지도를 `parent` 맨 앞에 만듦(참 = 섬 · 거짓 = WebGL 못 씀) · null = 버림. */ @@ -125,7 +127,7 @@ function fillEdge( } /** 겹 차례(아래 → 위)와 꼴 — 캔버스 `D03_PlanAnalysis_UI_Layers` 의 색 · 굵기 · 투명도 · 차례 그대로. */ -function layerSpecs(step: number, light: number): GlSpec[] { +function layerSpecs(step: number, light: number, pick: RoadPick | null): GlSpec[] { const contour = themeColor("--draw-map-contour", "#ffd68c"); const place = themeColor("--draw-map-place", "#f8fafc"); const landslideZone = themeColor("--draw-map-landslide-zone", "#e11d48"); @@ -235,11 +237,27 @@ function layerSpecs(step: number, light: number): GlSpec[] { ...haloLine( "forestRoadFile", "forestRoadFile", - themeColor("--draw-map-forest-road-file", "#ea580c"), + themeColor("--draw-map-forest-road-file", "#ff2fd0"), 2.4, [], { color: HALO, width: 4 }, ), + // 고른 묶음 · 선 강조(605-2) — 같은 자료에서 걸러 한 겹 더 굵게 + ...["-pick-halo", "-pick"].map( + (tail, i) => + ({ + key: "forestRoadFile", + id: `forestRoadFile${tail}`, + type: "line", + source: "forestRoadFile", + filter: pickFilter(pick), + layout: ROUND, + paint: { + "line-color": i ? themeColor("--draw-map-sketch", "#ffe94d") : HALO, + "line-width": i ? 4.2 : 7.4, + }, + }) as GlSpec, + ), { key: "stream", id: "stream", @@ -306,6 +324,13 @@ function layerSpecs(step: number, light: number): GlSpec[] { ]; } +/** 고른 묶음 · 선만 거름(고름 없음 = 아무것도 안 맞는 거름). */ +function pickFilter(pick: RoadPick | null): FilterSpecification { + const all: unknown[] = ["all", ["==", ["get", "__bundle"], pick?.bundle ?? ""]]; + if (pick?.line !== undefined) all.push(["==", ["get", "__line"], pick.line]); + return all as unknown as FilterSpecification; +} + /** 선 겹 색 값(캔버스 `LINE_LAYERS` 와 같음). */ const LINE_COLOR = { faults: "--draw-map-fault", @@ -327,8 +352,9 @@ const SAT_SATURATION = -0.3; export function createGlBase(projectId: string, onFail: (why: string) => void): GlBase { let step = 5; let light = SAT_LIGHT_DEFAULT; + let pick: RoadPick | null = null; const base = createGlMap({ - specs: () => layerSpecs(step, light), + specs: () => layerSpecs(step, light, pick), fixed: { satellite: satelliteTileSource(projectId) }, themeNames: THEME_NAMES, className: "d03-plan__gl", @@ -342,6 +368,12 @@ export function createGlBase(projectId: string, onFail: (why: string) => void): const map = base.map(); if (map?.getLayer("contour-minor")) map.setFilter("contour-minor", minorFilter(step)); }, + setRoadPick(next) { + pick = next; + const map = base.map(); + for (const id of ["forestRoadFile-pick-halo", "forestRoadFile-pick"]) + if (map?.getLayer(id)) map.setFilter(id, pickFilter(pick)); + }, setSatelliteLight(next) { light = next; const map = base.map(); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 4301c0d71..ad08c5b57 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -232,7 +232,15 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { [failed, lastTried, ready] = [null, "", true]; void ensureCovered(true); }, - onUpload: (files) => void uploadRoads(files), + onUpload: (files) => uploadRoads(files), + roads: { + projectId, + onPick: (pick, bounds) => { + backdrop.pickRoad(pick); + if (pick) fitTo(boxOfLonLat(epsg, bounds)); + }, + onDelete: (bundle) => backdrop.deleteRoad(bundle), + }, }); const siteCard = createPanelCard("위치", false); siteCard.append(...siteBox.parts); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_RoadList.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_RoadList.ts new file mode 100644 index 000000000..15cb6b183 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_RoadList.ts @@ -0,0 +1,186 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_RoadList.ts + * D03 「위치」 칸 올린 임도망 목록(PLAN 605-2 · 요구 476-1) — 묶음 줄(파일 이름 · 가닥 수 · 길이 · 지움) · + * 펼치면 선 줄 · 줄 고름 = 지도 강조 + 맞춤(페이지가 함) · 지움 = 물음 뒤 그 묶음만 · 자료는 서버 한 번 읽음. + * 부르는 곳 = `D03_PlanAnalysis_UI_Site.ts`. + * ========================================================================== */ + +import { createIconButton } from "@ui/ui_template_button"; +import { createListRow } from "@ui/ui_template_list_row"; +import { askConfirm } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; +import { + fetchPlanForestRoads, + type PlanForestRoads, + type PlanRoadBundle, +} from "./D03_PlanAnalysis_Api"; +import type { RoadPick } from "./D03_PlanAnalysis_UI_LayerDefs"; + +/** 한 묶음에서 줄로 펼치는 선 수 — 넘으면 「외 N가닥」(시군구 한 파일 수천 가닥이 칸을 안 밀게). */ +const LINE_ROWS_MAX = 200; +/** 선 이름으로 보는 속성 이름 끝(임도명 · 노선명 · NAME). */ +const NAME_KEY = /명$|name$/i; + +export interface RoadListParams { + projectId: string; + /** 고름(null = 해제) · 맞춤 상자([서, 남, 동, 북] 경위도). */ + onPick: (pick: RoadPick | null, bounds: number[] | null) => void; + /** 묶음 지움(서버 + 지도) — 거짓 = 못 지움(알림은 하는 쪽). */ + onDelete: (bundle: string) => Promise; +} + +export interface RoadList { + root: HTMLElement; + /** 서버에서 다시 읽어 그림 — 올린 뒤 · 지운 뒤 · 처음. */ + reload: () => Promise; + /** 지움 단추 — 올릴 수 있는 사람만. */ + setEditable: (editable: boolean) => void; +} + +const km = (m: number): string => + m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m`; + +/** GeoJSON 좌표 → [서, 남, 동, 북]. */ +function boxOf(coordinates: unknown, box = [Infinity, Infinity, -Infinity, -Infinity]): number[] { + if (Array.isArray(coordinates) && typeof coordinates[0] === "number") { + const [x, y] = coordinates as number[]; + box[0] = Math.min(box[0], x); + box[1] = Math.min(box[1], y); + box[2] = Math.max(box[2], x); + box[3] = Math.max(box[3], y); + } else if (Array.isArray(coordinates)) for (const part of coordinates) boxOf(part, box); + return box; +} + +export function createRoadList(params: RoadListParams): RoadList { + const root = document.createElement("div"); + root.className = "d03-plan__pieces"; + root.hidden = true; + let info: PlanForestRoads | null = null; + let picked: RoadPick | null = null; + let editable = true; + const open = new Set(); + let turn = 0; // 옛 답 버림 + + const isControl = (event: Event): boolean => + !!(event.target as Element).closest("input, select, button, .ui-list-row__trailing"); + const pickable = (row: HTMLElement, onPick: () => void): HTMLElement => { + row.tabIndex = 0; + row.addEventListener("click", (event) => isControl(event) || onPick()); + row.addEventListener("keydown", (event) => { + if (event.key === "Enter" && event.target === row) onPick(); + }); + return row; + }; + + function choose(pick: RoadPick, bounds: number[] | null): void { + const same = picked?.bundle === pick.bundle && picked.line === pick.line; + picked = same ? null : pick; + params.onPick(picked, same ? null : bounds); + render(); + } + + function lineRows(bundle: PlanRoadBundle): HTMLElement[] { + const features = (info?.collection.features ?? []).filter( + (feature) => feature.properties?.["__bundle"] === bundle.id, + ); + const rows = features.slice(0, LINE_ROWS_MAX).map((feature, index) => { + const props = feature.properties ?? {}; + const line = Number(props["__line"] ?? index); + const key = Object.keys(props).find((k) => NAME_KEY.test(k) && typeof props[k] === "string"); + const row = createListRow({ + label: (key && String(props[key]).trim()) || `선 ${line + 1}`, + sub: km(Number(props["length_m"] ?? 0)), + size: 28, + active: picked?.bundle === bundle.id && picked.line === line, + }); + row.dataset.line = String(line); + return pickable(row, () => + choose( + { bundle: bundle.id, line }, + boxOf((feature.geometry as { coordinates?: unknown })?.coordinates), + ), + ); + }); + if (features.length > LINE_ROWS_MAX) + rows.push(createText(`외 ${features.length - LINE_ROWS_MAX}가닥`, "muted", 12)); + return rows; + } + + function render(): void { + const bundles = info?.bundles ?? []; + root.hidden = !bundles.length; + if (!bundles.length) return void root.replaceChildren(); + const rows: HTMLElement[] = [createText(`올린 임도망 ${bundles.length}묶음`, "muted", 12)]; + for (const bundle of bundles) { + const shown = open.has(bundle.id); + const fold = createIconButton({ + icon: shown ? "chevronDown" : "chevronRight", + title: shown ? "선 접기" : "선 펼치기", + size: 24, + onClick: () => { + if (!open.delete(bundle.id)) open.add(bundle.id); + render(); + }, + }); + fold.dataset.role = "road-fold"; + const drop = createIconButton({ + icon: "trash", + title: "이 묶음 지움", + size: 24, + onClick: async () => { + const ok = await askConfirm({ + message: `올린 임도망 「${bundle.name}」 묶음을 지울까요? 지도에서도 빠집니다.`, + okLabel: "지움", + danger: true, + }); + if (!ok || !(await params.onDelete(bundle.id))) return; + open.delete(bundle.id); + if (picked?.bundle === bundle.id) picked = null; + await reload(); + }, + }); + drop.dataset.role = "road-drop"; + drop.hidden = !editable; + const tail = document.createElement("span"); + tail.className = "d03-plan__line-tail"; + tail.append(fold, drop); + const head = createListRow({ + label: bundle.name, + sub: bundle.count + ? `${bundle.count}가닥 · ${km(bundle.length_m)}` + : `범위 안 선 없음 (파일 ${bundle.total}가닥)`, + size: 32, + trailing: tail, + title: bundle.files.join(" · "), + active: picked?.bundle === bundle.id && picked.line === undefined, + }); + head.dataset.bundle = bundle.id; + rows.push(pickable(head, () => choose({ bundle: bundle.id }, bundle.bounds))); + if (shown) rows.push(...lineRows(bundle)); + } + root.replaceChildren(...rows); + } + + async function reload(): Promise { + const at = ++turn; + const got = await fetchPlanForestRoads(params.projectId).catch(() => null); // 올린 적 없음 = 404 + if (at !== turn) return; + info = got; + if (picked && !got?.bundles.some((b) => b.id === picked?.bundle)) { + picked = null; + params.onPick(null, null); + } + render(); + } + + void reload(); + return { + root, + reload, + setEditable(next) { + editable = next; + render(); + }, + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts index b788950eb..cc48c9dbe 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts @@ -2,7 +2,8 @@ * D03_PlanAnalysis_UI_Site.ts * D03 「위치」 칸(PLAN 596-2 · 요구 463-3~6 · 19) — 지금 기준점 주소 한 줄 · 주소 · 장소 찾기 * (서버 VWorld `searchPlaces` · 부품 `createFindList` · Enter = 찾기 · ↑ ↓ Enter = 고름) · - * 알림 한 줄(받기 실패 · 끊김 · 기준점 없음 · 못 불러옴만) + [다시 받기] · [임도망 올리기]. + * 알림 한 줄(받기 실패 · 끊김 · 기준점 없음 · 못 불러옴만) + [다시 받기] · [임도망 올리기] + 올린 임도망 목록 + * (`D03_PlanAnalysis_UI_RoadList.ts` · 605-2). * 고름 = 페이지가 받기 물음(멂 · 다른 TM 띠) → 기준점 캐시 → 지도 이동 → 둘레 받기 · [저장] 때 정본(등록과 한 칸). * ========================================================================== */ @@ -12,6 +13,7 @@ import { createFindList, type FindList, type FindRow } from "@ui/ui_template_fin import { t } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; import { searchPlaces, type PlaceHit } from "./D03_PlanAnalysis_Api"; +import { createRoadList, type RoadListParams } from "./D03_PlanAnalysis_UI_RoadList"; /** 찾기 글 최소 길이(서버 VWorld 한 글자 = 쓸모없는 후보). */ const MIN_QUERY = 2; @@ -20,7 +22,10 @@ export interface PlanSiteParams { /** 후보 고름 — 받기 · 캐시 · 이동은 페이지. */ onPick: (hit: PlaceHit) => void; onRetry: () => void; - onUpload: (files: File[]) => void; + /** 올림(끝나면 목록을 다시 읽음). */ + onUpload: (files: File[]) => Promise | void; + /** 올린 임도망 목록(605-2) — 고름 · 지움은 페이지. */ + roads: RoadListParams; } export interface PlanSiteState { @@ -104,22 +109,26 @@ export function createPlanSite(params: PlanSiteParams): PlanSite { size: 28, onClick: params.onRetry, }); + const roads = createRoadList(params.roads); const upload = createButton({ label: "임도망 올리기", variant: "ghost", size: 28, onClick: () => - pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, params.onUpload), + pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, (files) => + Promise.resolve(params.onUpload(files)).then(() => roads.reload()), + ), }); upload.title = "임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)"; return { - parts: [address, find.root, note, retry, upload], + parts: [address, find.root, note, retry, upload, roads.root], render(state) { address.textContent = state.address ?? ""; address.hidden = !state.address; note.textContent = state.note; note.hidden = !state.note; find.root.hidden = upload.hidden = !state.editable; + roads.setEditable(state.editable); retry.hidden = !state.editable || !state.retry; }, }; diff --git a/resources/tester/test_584_3_plan_forest_road_zones.py b/resources/tester/test_584_3_plan_forest_road_zones.py index 103d1c14e..9526c0e00 100644 --- a/resources/tester/test_584_3_plan_forest_road_zones.py +++ b/resources/tester/test_584_3_plan_forest_road_zones.py @@ -62,8 +62,8 @@ def test_save_roads_files_clip_and_saved(tmp_path) -> None: 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 len(list((tmp_path / fr.FOLDER / fr.BUNDLE_DIR).glob("*.zip"))) == 1 + assert not list((tmp_path / fr.FOLDER).rglob("*.new")) assert fr.road_layer(tmp_path, BOX, EPSG) == json.loads(json.dumps(got)) # 다시 열면 저장본 @@ -77,7 +77,8 @@ def test_zip_in_folder_and_missing_prj_is_5179(tmp_path) -> None: 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 + folder = tmp_path / fr.FOLDER / fr.BUNDLE_DIR + before = sorted(p.name for p in folder.iterdir()) bad = [ ([("a.zip", b"not a zip")], "zip 파일을 열지"), ([(n, d) for n, d in files if not n.endswith(".dbf")], ".dbf"), @@ -87,9 +88,8 @@ def test_bad_uploads_keep_old(tmp_path) -> None: 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 sorted(p.name for p in folder.iterdir()) == before # 쌓인 것 그대로 assert fr.road_layer(tmp_path, BOX, EPSG)["count"] == first["count"] - assert not list((tmp_path / fr.FOLDER).glob("*.new")) def test_clip_box_site_5km_and_distance(tmp_path) -> None: @@ -112,6 +112,67 @@ def test_clip_box_site_5km_and_distance(tmp_path) -> None: fr.save_roads(tmp_path, far, frame, EPSG, site) +def test_bundles_stack_and_delete(tmp_path) -> None: + """605-1 — 올릴 때마다 묶음 · 합친 값 + 묶음 줄 · 지움 = 그 묶음만 · 옛 한 파일 꼴 = legacy.""" + files = _shp_files(tmp_path / "shp", LINES) + one = fr.save_roads(tmp_path, files, BOX, EPSG) + two = fr.save_roads(tmp_path, [("copy.zip", _zip(dict(files)))], BOX, EPSG) + assert [b["name"] for b in two["bundles"]] == [ + "47250", + "copy.zip", + ] # 낱 파일 = 첫 파일 이름 줄기 + assert two["count"] == 2 * one["count"] and two["total"] == 6 + assert two["length_m"] == pytest.approx(2 * one["length_m"], abs=0.2) + assert {f["properties"]["__bundle"] for f in two["collection"]["features"]} == { + b["id"] for b in two["bundles"] + } + assert all(b["count"] == 2 and b["length_m"] > 1200 for b in two["bundles"]) + for b in two["bundles"]: # 묶음 안 선 차례 + mine = [ + f["properties"] + for f in two["collection"]["features"] + if f["properties"]["__bundle"] == b["id"] + ] + assert [p["__line"] for p in mine] == [0, 1] + gone = two["bundles"][0]["id"] + fr.delete_bundle(tmp_path, gone) + rest = fr.road_layer(tmp_path, BOX, EPSG) + assert [b["id"] for b in rest["bundles"]] == [two["bundles"][1]["id"]] + assert rest["count"] == one["count"] + for bad in (gone, "../x", "zz"): + with pytest.raises(FileNotFoundError): + fr.delete_bundle(tmp_path, bad) + fr.delete_bundle(tmp_path, rest["bundles"][0]["id"]) + with pytest.raises(FileNotFoundError): + fr.road_layer(tmp_path, BOX, EPSG) + + +def test_legacy_single_file_reads_as_one_bundle(tmp_path) -> None: + folder = tmp_path / fr.FOLDER + folder.mkdir() + (folder / fr.SOURCE_ZIP).write_bytes(fr.pack(_shp_files(tmp_path / "shp", LINES))) + (folder / fr.SOURCE_META).write_text( + json.dumps({"files": ["옛.zip"], "bytes": 1, "uploaded_at": "2026-01-01T00:00:00Z"}), + "utf-8", + ) + got = fr.road_layer(tmp_path, BOX, EPSG) + assert [(b["id"], b["name"]) for b in got["bundles"]] == [(fr.LEGACY, "옛.zip")] + assert got["count"] == 2 + new = fr.save_roads(tmp_path, _shp_files(tmp_path / "n", LINES), BOX, EPSG) + assert [b["id"] for b in new["bundles"]][0] == fr.LEGACY and len(new["bundles"]) == 2 + fr.delete_bundle(tmp_path, fr.LEGACY) + assert not (folder / fr.SOURCE_ZIP).exists() and not (folder / fr.SOURCE_META).exists() + assert len(fr.road_layer(tmp_path, BOX, EPSG)["bundles"]) == 1 + + +def test_bundle_limit(tmp_path) -> None: + files = _shp_files(tmp_path / "shp", LINES) + for _ in range(fr.MAX_BUNDLES): + fr.save_roads(tmp_path, files, BOX, EPSG) + with pytest.raises(ValueError, match="하나를 지우고"): + fr.save_roads(tmp_path, files, BOX, EPSG) + + def test_no_upload_is_not_found(tmp_path) -> None: with pytest.raises(FileNotFoundError): fr.road_layer(tmp_path, BOX, EPSG) @@ -121,6 +182,9 @@ 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 + assert ( + "forest-roads/bundles" in router and "forest-roads/{bundle}" in router + ) # 605-1 목록 · 지움 # ── 산사태취약지역 표 ── @@ -281,6 +345,18 @@ def test_screen_defs() -> None: assert "uploadRoads" in page and "uploadPlanForestRoads(" in page assert "") # 겹 모두 바탕 + # 605-2 — 밝은 분홍 · 목록(부품 상자만) · 고름 = 강조(GL 거름 · 캔버스) · 지움 = 물음 뒤 + assert re.search(r"--draw-map-forest-road-file: #ff2fd0;", theme) + mapgl = (folder / "D03_PlanAnalysis_UI_MapGl.ts").read_text("utf-8") + roads = (folder / "D03_PlanAnalysis_UI_RoadList.ts").read_text("utf-8") + assert "forestRoadFile-pick-halo" in mapgl and "setRoadPick" in mapgl + assert "roadPick" in paint and "deletePlanForestRoad(" in page and "pickRoad" in page + assert "askConfirm(" in roads and "danger: true" in roads and "createListRow" in roads + assert ( + "