feat(D 예정노선): 올린 임도망 묶음 쌓기 · 목록(고르기 · 지움) · 지도 밝은 분홍 + 검은 테 (PLAN 605-1 · 605-2 · 요구 476-1)

- 서버 — 올릴 때마다 묶음 하나(`D03_PlanAnalysis/임도망도/<번호>`) · 옛 한 파일 꼴 = 묶음 legacy · 합친 값 + 묶음 줄 · 목록 GET · 묶음 DELETE · 20 묶음까지
- 화면 — 위치 칸 목록(묶음 줄 · 펼치면 선 줄 · 고름 = 지도 강조 + 맞춤 · 지움 = 물음 뒤) · 색 주황 → 밝은 분홍(`--draw-map-forest-road-file`)
- 시험 584_3 — 묶음 쌓기 · 지움 · legacy · 20 묶음 · 화면 정의

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 21:49:20 +09:00
co-authored by Claude Sonnet 5.5
parent 20ed22709c
commit 40cecf27a2
12 changed files with 629 additions and 53 deletions
+35 -2
View File
@@ -251,9 +251,25 @@ export const fetchPlanZeroOrder = (projectId: string): Promise<PlanZeroOrder> =>
export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> => export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> =>
request(`${base(projectId)}/layers/bands`); request(`${base(projectId)}/layers/bands`);
/** 올린 임도망도(584-3 · 595-4) — 기준점 ±5 km ∪ 받은 지형으로 자른 선 + 가닥 · 길이 m · 원본 선 수 · /** 올린 묶음 한 줄(605-1) — 올릴 때마다 하나 · 자른 가닥 · 길이 m · 원본 선 수 · 잘린 상자 · 기준점 거리. */
* 좌표계 · 잘린 상자([서, 남, 동, 북] 경위도) · 기준점 ~ 가장 가까운 임도 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 { export interface PlanForestRoads {
bundles: PlanRoadBundle[];
count: number; count: number;
length_m: number; length_m: number;
total: number; total: number;
@@ -268,6 +284,23 @@ export interface PlanForestRoads {
export const fetchPlanForestRoads = (projectId: string): Promise<PlanForestRoads> => export const fetchPlanForestRoads = (projectId: string): Promise<PlanForestRoads> =>
request(`${base(projectId)}/layers/forest-roads`); request(`${base(projectId)}/layers/forest-roads`);
/** 묶음 하나 지움(605-1) — 남은 묶음 합친 값 · 하나도 안 남으면 null. */
export async function deletePlanForestRoad(
projectId: string,
bundle: string,
): Promise<PlanForestRoads | null> {
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 묶음) — 못 읽으면 서버 글 그대로 오류. */ /** 임도망도 SHP 올리기(zip 하나 · shp · shx · dbf · prj 묶음) — 못 읽으면 서버 글 그대로 오류. */
export async function uploadPlanForestRoads( export async function uploadPlanForestRoads(
projectId: string, projectId: string,
+138 -25
View File
@@ -4,8 +4,9 @@
- 올림 = zip 하나 또는 shp · shx · dbf(· prj · cpg) 묶음 · 합계 `MAX_BYTES` · 읽어 보고 자르는 범위 - 올림 = zip 하나 또는 shp · shx · dbf(· prj · cpg) 묶음 · 합계 `MAX_BYTES` · 읽어 보고 자르는 범위
안 선이 있어야 받음(아니면 옛 것 그대로 · 까닭 = `ValueError` + 기준점 ~ 가장 가까운 임도 거리) 안 선이 있어야 받음(아니면 옛 것 그대로 · 까닭 = `ValueError` + 기준점 ~ 가장 가까운 임도 거리)
- 자르는 범위 = 기준점 ± `RADIUS_M` ∪ 받은 지형(`clip_box` · 지형 받기 전에도 기준점만으로) - 자르는 범위 = 기준점 ± `RADIUS_M` ∪ 받은 지형(`clip_box` · 지형 받기 전에도 기준점만으로)
- 원본 = 프로젝트 `D03_PlanAnalysis/임도망도.zip`(낱 파일은 zip 으로 묶음) + - 원본 = 올릴 때마다 묶음 하나 — 프로젝트 `D03_PlanAnalysis/임도망도/<번호>.zip`(낱 파일은 zip 으로
`임도망도.json`(파일 이름 · 올린 때) 묶음) + `<번호>.json`(파일 이름 · 올린 때) · 옛 한 파일 꼴(`임도망도.zip` · `.json`)은 묶음
`legacy` 하나로 읽음 · 지움 = 그 묶음만(PLAN 605-1)
- 좌표계 = prj · 없으면 EPSG:5179 · dbf 글자 = cpg · 없으면 cp949 · 자르는 범위로 잘라 가닥 · 길이 · - 좌표계 = prj · 없으면 EPSG:5179 · dbf 글자 = cpg · 없으면 cp949 · 자르는 범위로 잘라 가닥 · 길이 ·
잘린 상자(`bounds` 경위도) · 기준점 거리 → 파생 저장본(열쇠 = 원본 지문 · 범위 · 기준점) 잘린 상자(`bounds` 경위도) · 기준점 거리 → 파생 저장본(열쇠 = 원본 지문 · 범위 · 기준점)
@@ -18,7 +19,9 @@ import io
import json import json
import math import math
import os import os
import re
import tempfile import tempfile
import uuid
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -36,14 +39,18 @@ Box = tuple[float, float, float, float]
XY = tuple[float, float] XY = tuple[float, float]
FOLDER = "D03_PlanAnalysis" FOLDER = "D03_PlanAnalysis"
SOURCE_ZIP = "임도망도.zip" SOURCE_ZIP = "임도망도.zip" # 옛 한 파일 꼴 = 묶음 legacy
SOURCE_META = "임도망도.json" SOURCE_META = "임도망도.json"
BUNDLE_DIR = "임도망도"
LEGACY = "legacy"
MAX_BUNDLES = 20
DEFAULT_EPSG = 5179 DEFAULT_EPSG = 5179
DBF_ENCODING = "cp949" DBF_ENCODING = "cp949"
MAX_BYTES = 100 * 1024 * 1024 MAX_BYTES = 100 * 1024 * 1024
SHP_PARTS = (".shp", ".shx", ".dbf", ".prj", ".cpg") SHP_PARTS = (".shp", ".shx", ".dbf", ".prj", ".cpg")
ROAD_KIND = "plan_forest_road" ROAD_KIND = "plan_forest_road"
ROAD_VERSION = 2 # 잘린 상자 · 기준점 거리 ROAD_VERSION = 3 # 묶음 열쇠
KEEP = 2 * MAX_BUNDLES # 묶음마다 파생 저장본 한 벌
RADIUS_M = 5000.0 # 기준점 둘레(요구서_463 3장 18) 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]: def _paths(project_root: Path, bundle: str) -> tuple[Path, Path]:
"""올린 임도망도 — 원본 지문 · 범위 · 기준점 같으면 저장본 · 올린 적 없으면 """(zip, 이름표 json) — `legacy` = 옛 한 파일 꼴."""
`FileNotFoundError`.""" folder = source_dir(project_root)
path = source_dir(project_root) / SOURCE_ZIP 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) stamp = file_stamp(path)
if stamp is None: if stamp is None:
raise FileNotFoundError( raise FileNotFoundError(f"올린 임도망도 묶음 {bundle} 이(가) 없습니다.")
"올린 임도망도가 없습니다 — [임도망 올리기]로 시군구 SHP 를 올리세요."
)
at = [round(v, 1) for v in origin] if origin else None 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) saved = read_derived(project_root, ROAD_KIND, key)
if saved is not None: if saved is not None:
return saved["value"] return saved["value"]
value = clip_roads(path, box, epsg, origin) value = clip_roads(path, box, epsg, origin)
try: value["source"] = _meta(meta) or None
value["source"] = json.loads((source_dir(project_root) / SOURCE_META).read_text("utf-8")) write_derived(project_root, ROAD_KIND, key, value, KEEP)
except (OSError, ValueError):
value["source"] = None
write_derived(project_root, ROAD_KIND, key, value)
return value 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( def save_roads(
project_root: Path, project_root: Path,
files: list[tuple[str, bytes]], files: list[tuple[str, bytes]],
@@ -199,12 +294,17 @@ def save_roads(
epsg: int, epsg: int,
origin: XY | None = None, origin: XY | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""올림 — 묶어 읽어 보고 자르는 범위 안 선이 있으면 원본을 바꿈 · 아니면 `ValueError` """올림 — 묶어 읽어 보고 자르는 범위 안 선이 있으면 새 묶음으로 쌓음 · 아니면 `ValueError`
(옛 것 그대로 · 기준점 ~ 가장 가까운 임도 거리).""" (쌓인 것 그대로 · 기준점 ~ 가장 가까운 임도 거리)."""
if len(bundle_ids(project_root)) >= MAX_BUNDLES:
raise ValueError(f"올린 묶음이 {MAX_BUNDLES} 개입니다 — 하나를 지우고 올리세요.")
data = pack(files) data = pack(files)
folder = source_dir(project_root) bundle = uuid.uuid4().hex[:12]
folder.mkdir(parents=True, exist_ok=True) zip_path, meta_path = _paths(project_root, bundle)
with tempfile.NamedTemporaryFile(dir=folder, suffix=".zip.new", delete=False) as temporary: 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) temporary.write(data)
trial = Path(temporary.name) trial = Path(temporary.name)
try: try:
@@ -216,15 +316,28 @@ def save_roads(
f"임도가 기준점 ±{RADIUS_M / 1000:.0f} km · 받은 지형 밖뿐입니다{where}" f"임도가 기준점 ±{RADIUS_M / 1000:.0f} km · 받은 지형 밖뿐입니다{where}"
f"(파일 선 {got['total']}가닥) — 사업지 시군구 파일인지 보세요." f"(파일 선 {got['total']}가닥) — 사업지 시군구 파일인지 보세요."
) )
os.replace(trial, folder / SOURCE_ZIP) os.replace(trial, zip_path)
finally: finally:
trial.unlink(missing_ok=True) trial.unlink(missing_ok=True)
names = [Path(name).name for name, _ in files]
atomic_write_json( atomic_write_json(
folder / SOURCE_META, meta_path,
{ {
"files": [Path(name).name for name, _ in files], "files": names,
"name": _name(names),
"bytes": len(data), "bytes": len(data),
"uploaded_at": utc_now_text(), "uploaded_at": utc_now_text(),
}, },
) )
return road_layer(project_root, box, epsg, origin) 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)
+30 -4
View File
@@ -6,8 +6,8 @@
- `GET …/plan/tile/{z}/{x}/{y}` — 위성 조각 대리 · 서버 기억(586 GL 바탕) - `GET …/plan/tile/{z}/{x}/{y}` — 위성 조각 대리 · 서버 기억(586 GL 바탕)
- `GET …/plan/layers/sheet?key=` · `…/cadastral` · `…/raster?kind=slope|hillshade|landslide` · - `GET …/plan/layers/sheet?key=` · `…/cadastral` · `…/raster?kind=slope|hillshade|landslide` ·
`…/zones` · `…/soils` · `…/bands` · `…/lines?kind=` · `…/zero-order` · `…/zones` · `…/soils` · `…/bands` · `…/lines?kind=` · `…/zero-order` ·
`…/forest-roads`(GET · POST 올리기) · `…/landslide-zones` — 지도 겹 `…/forest-roads`(GET · POST 올리기 · `/bundles` 목록 · DELETE `/<번호>`) ·
(PLAN 569-1 · 581-1 · 581-2 · 584-1 · 584-3) `…/landslide-zones` — 지도 겹 (PLAN 569-1 · 581-1 · 581-2 · 584-1 · 584-3 · 605-1)
범위 = 기준점 둘레(`SITE_RADIUS_M`) + 지점 + 그은 점(`extra` · 지도 보기 귀) 상자 + 여유 ∪ 받은 범위 범위 = 기준점 둘레(`SITE_RADIUS_M`) + 지점 + 그은 점(`extra` · 지도 보기 귀) 상자 + 여유 ∪ 받은 범위
(PLAN 591-2 · 기준점 = 처음 받는 기준일 뿐). (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) @router.get("/{project_id}/plan/layers/forest-roads", response_model=None)
async def plan_layer_forest_roads(project_id: UUID) -> dict[str, Any]: async def plan_layer_forest_roads(project_id: UUID) -> dict[str, Any]:
"""올린 임도망도(기준점 ± 5 km ∪ 받은 지형으로 자른 선 · 가닥 · 길이 · 잘린 상자 · 기준점 """올린 임도망도(묶음마다 기준점 ± 5 km ∪ 받은 지형으로 자른 선 · 합친 가닥 · 길이 · 잘린 상자 ·
거리) — 올린 적 없으면 404.""" 기준점 거리 · `bundles` 묶음 줄) — 올린 적 없으면 404."""
root, epsg, box, origin = await _road_frame(project_id) root, epsg, box, origin = await _road_frame(project_id)
try: try:
value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg, origin) 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} 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) @router.post("/{project_id}/plan/layers/forest-roads", response_model=None)
async def plan_layer_forest_roads_upload( async def plan_layer_forest_roads_upload(
project_id: UUID, files: list[UploadFile] = File(...) project_id: UUID, files: list[UploadFile] = File(...)
@@ -42,6 +42,12 @@ export type PlanLayerKey =
| "building" | "building"
| "place"; | "place";
/** 올린 임도망 고름(605-2) — 묶음 하나 또는 그 안의 선 하나(지도 강조). */
export interface RoadPick {
bundle: string;
line?: number;
}
export interface LayerDef { export interface LayerDef {
key: PlanLayerKey; key: PlanLayerKey;
icon: IconName; icon: IconName;
@@ -70,7 +76,8 @@ export const GROUPS: readonly (readonly LayerDef[])[] = [
{ {
key: "forestRoadFile", key: "forestRoadFile",
icon: "map", icon: "map",
title: "올린 임도망도(산림청 시군구 SHP = 진한 주황 실선 · 왼쪽 「위치」 칸에서 올림)", title:
"올린 임도망도(산림청 시군구 SHP = 밝은 분홍 실선 · 왼쪽 「위치」 칸에서 올리고 묶음 고름 · 지움)",
on: false, on: false,
}, },
], ],
@@ -12,6 +12,7 @@ import {
drawPreparedLabels, drawPreparedLabels,
lonLatToScreen, lonLatToScreen,
pickLevelStep, pickLevelStep,
type PreparedLayer,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { import {
LINE_KEYS, LINE_KEYS,
@@ -30,6 +31,8 @@ export function createCanvasLayers(opts: {
on: Record<PlanLayerKey, boolean>; on: Record<PlanLayerKey, boolean>;
data: Partial<Prepared>; data: Partial<Prepared>;
meta: () => VWorldMeta | null; meta: () => VWorldMeta | null;
/** 고른 임도망 묶음 · 선(605-2) — 없으면 null. */
roadPick: () => PreparedLayer | null;
}): PlanMapLayer[] { }): PlanMapLayer[] {
const { on, data } = opts; const { on, data } = opts;
/** 등고 글 폭 기억(글꼴 하나 · 582). */ /** 등고 글 폭 기억(글꼴 하나 · 582). */
@@ -173,7 +176,11 @@ export function createCanvasLayers(opts: {
const got = on.forestRoadFile && data.forestRoadFile; const got = on.forestRoadFile && data.forestRoadFile;
if (!got) return; if (!got) return;
stroke(context, map, got.layer, "rgba(0, 0, 0, 0.55)", 4); 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);
}, },
}, },
{ {
+84 -5
View File
@@ -52,6 +52,7 @@ import {
fetchPlanSoils, fetchPlanSoils,
fetchPlanZeroOrder, fetchPlanZeroOrder,
fetchPlanZones, fetchPlanZones,
deletePlanForestRoad,
uploadPlanForestRoads, uploadPlanForestRoads,
type PlanForestRoads, type PlanForestRoads,
} from "./D03_PlanAnalysis_Api"; } from "./D03_PlanAnalysis_Api";
@@ -69,6 +70,7 @@ import {
readPref, readPref,
shadeImage, shadeImage,
type PlanLayerKey, type PlanLayerKey,
type RoadPick,
} from "./D03_PlanAnalysis_UI_LayerDefs"; } from "./D03_PlanAnalysis_UI_LayerDefs";
import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend"; import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend";
import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint"; import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint";
@@ -110,6 +112,10 @@ export interface PlanLayers {
load: () => Promise<void>; load: () => Promise<void>;
/** 임도망도 올리기(왼쪽 「위치」 칸) — 받으면 그 겹을 켬 · 못 받으면 알림 + null. */ /** 임도망도 올리기(왼쪽 「위치」 칸) — 받으면 그 겹을 켬 · 못 받으면 알림 + null. */
uploadRoads: (files: File[]) => Promise<PlanForestRoads | null>; uploadRoads: (files: File[]) => Promise<PlanForestRoads | null>;
/** 올린 묶음 하나 지움(605-1) — 지도에서 바로 빠짐 · 못 지우면 알림 + 거짓. */
deleteRoad: (bundle: string) => Promise<boolean>;
/** 올린 묶음 · 선 고름(605-2) — 지도 강조(꺼 있던 겹은 켬) · null = 강조 없앰. */
pickRoad: (pick: RoadPick | null) => void;
/** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */ /** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */
state: () => { state: () => {
on: Record<PlanLayerKey, boolean>; on: Record<PlanLayerKey, boolean>;
@@ -294,11 +300,7 @@ export function createPlanLayers(opts: {
const at = meta; const at = meta;
const job = uploadPlanForestRoads(projectId, files).then((info) => { const job = uploadPlanForestRoads(projectId, files).then((info) => {
if (meta !== at) return info; if (meta !== at) return info;
if (at) applyRoads(info);
data.forestRoadFile = { layer: prepareLayer(info.collection, createNormalizer(at)), info };
gl.setData("forestRoadFile", info.collection);
on.forestRoadFile = true;
writeByKey(PREF_KEY, JSON.stringify(on));
showToast( showToast(
`임도망도 받음 — 범위 안 ${info.count}가닥 ${(info.length_m / 1000).toFixed(2)} km (파일 ${info.total}가닥)`, `임도망도 받음 — 범위 안 ${info.count}가닥 ${(info.length_m / 1000).toFixed(2)} km (파일 ${info.total}가닥)`,
"success", "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<boolean> {
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 haveTag = (): string => (opts.have() ?? []).map((v) => Math.round(v)).join("_");
const sheetLayer = (layer: string, extra = ""): Promise<GeoJsonCollection> => const sheetLayer = (layer: string, extra = ""): Promise<GeoJsonCollection> =>
@@ -538,6 +614,7 @@ export function createPlanLayers(opts: {
on, on,
data, data,
meta: () => meta, meta: () => meta,
roadPick: pickLayer,
}); });
/** GL 이면 캔버스 바탕 겹은 쉼 — 등고 주곡선 간격(화면 안 가닥 수)만 셈. */ /** GL 이면 캔버스 바탕 겹은 쉼 — 등고 주곡선 간격(화면 안 가닥 수)만 셈. */
@@ -581,6 +658,8 @@ export function createPlanLayers(opts: {
layers, layers,
load, load,
uploadRoads, uploadRoads,
deleteRoad,
pickRoad,
state: () => ({ state: () => ({
on: { ...on }, on: { ...on },
loaded: LAYERS.map((def) => def.key).filter(loadedOf), loaded: LAYERS.map((def) => def.key).filter(loadedOf),
+36 -4
View File
@@ -23,7 +23,7 @@ import {
} from "@ui/ui_template_gl_map"; } from "@ui/ui_template_gl_map";
import { themeColor } from "@ui/ui_template_palette"; import { themeColor } from "@ui/ui_template_palette";
import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; 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"; import { SAT_LIGHT_DEFAULT } from "./D03_PlanAnalysis_UI_SatLight";
export { GL_FONT, type GlImage }; export { GL_FONT, type GlImage };
@@ -39,6 +39,8 @@ export interface GlBase {
setVisible: (key: PlanLayerKey, on: boolean) => void; setVisible: (key: PlanLayerKey, on: boolean) => void;
/** 등고선 주곡선 간격(m) — 캔버스와 같은 「화면 안 가닥 수」 규칙. */ /** 등고선 주곡선 간격(m) — 캔버스와 같은 「화면 안 가닥 수」 규칙. */
setContourStep: (step: number) => void; setContourStep: (step: number) => void;
/** 올린 임도망 고름(605-2) — 그 묶음 · 선만 굵게 강조 · null = 없앰. */
setRoadPick: (pick: RoadPick | null) => void;
/** 위성 밝기 상한(0~1) — 바로 반영 · 지도를 다시 세워도 유지. */ /** 위성 밝기 상한(0~1) — 바로 반영 · 지도를 다시 세워도 유지. */
setSatelliteLight: (level: number) => void; setSatelliteLight: (level: number) => void;
/** 켬 = 지도를 `parent` 맨 앞에 만듦(참 = 섬 · 거짓 = WebGL 못 씀) · null = 버림. */ /** 켬 = 지도를 `parent` 맨 앞에 만듦(참 = 섬 · 거짓 = WebGL 못 씀) · null = 버림. */
@@ -125,7 +127,7 @@ function fillEdge(
} }
/** 겹 차례(아래 → 위)와 꼴 — 캔버스 `D03_PlanAnalysis_UI_Layers` 의 색 · 굵기 · 투명도 · 차례 그대로. */ /** 겹 차례(아래 → 위)와 꼴 — 캔버스 `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 contour = themeColor("--draw-map-contour", "#ffd68c");
const place = themeColor("--draw-map-place", "#f8fafc"); const place = themeColor("--draw-map-place", "#f8fafc");
const landslideZone = themeColor("--draw-map-landslide-zone", "#e11d48"); const landslideZone = themeColor("--draw-map-landslide-zone", "#e11d48");
@@ -235,11 +237,27 @@ function layerSpecs(step: number, light: number): GlSpec[] {
...haloLine( ...haloLine(
"forestRoadFile", "forestRoadFile",
"forestRoadFile", "forestRoadFile",
themeColor("--draw-map-forest-road-file", "#ea580c"), themeColor("--draw-map-forest-road-file", "#ff2fd0"),
2.4, 2.4,
[], [],
{ color: HALO, width: 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", key: "stream",
id: "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` 와 같음). */ /** 선 겹 색 값(캔버스 `LINE_LAYERS` 와 같음). */
const LINE_COLOR = { const LINE_COLOR = {
faults: "--draw-map-fault", faults: "--draw-map-fault",
@@ -327,8 +352,9 @@ const SAT_SATURATION = -0.3;
export function createGlBase(projectId: string, onFail: (why: string) => void): GlBase { export function createGlBase(projectId: string, onFail: (why: string) => void): GlBase {
let step = 5; let step = 5;
let light = SAT_LIGHT_DEFAULT; let light = SAT_LIGHT_DEFAULT;
let pick: RoadPick | null = null;
const base = createGlMap<PlanLayerKey>({ const base = createGlMap<PlanLayerKey>({
specs: () => layerSpecs(step, light), specs: () => layerSpecs(step, light, pick),
fixed: { satellite: satelliteTileSource(projectId) }, fixed: { satellite: satelliteTileSource(projectId) },
themeNames: THEME_NAMES, themeNames: THEME_NAMES,
className: "d03-plan__gl", className: "d03-plan__gl",
@@ -342,6 +368,12 @@ export function createGlBase(projectId: string, onFail: (why: string) => void):
const map = base.map(); const map = base.map();
if (map?.getLayer("contour-minor")) map.setFilter("contour-minor", minorFilter(step)); 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) { setSatelliteLight(next) {
light = next; light = next;
const map = base.map(); const map = base.map();
+9 -1
View File
@@ -232,7 +232,15 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
[failed, lastTried, ready] = [null, "", true]; [failed, lastTried, ready] = [null, "", true];
void ensureCovered(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); const siteCard = createPanelCard("위치", false);
siteCard.append(...siteBox.parts); siteCard.append(...siteBox.parts);
@@ -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<boolean>;
}
export interface RoadList {
root: HTMLElement;
/** 서버에서 다시 읽어 그림 — 올린 뒤 · 지운 뒤 · 처음. */
reload: () => Promise<void>;
/** 지움 단추 — 올릴 수 있는 사람만. */
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<string>();
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<void> {
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();
},
};
}
+13 -4
View File
@@ -2,7 +2,8 @@
* D03_PlanAnalysis_UI_Site.ts * D03_PlanAnalysis_UI_Site.ts
* D03 「위치」 칸(PLAN 596-2 · 요구 463-3~6 · 19) — 지금 기준점 주소 한 줄 · 주소 · 장소 찾기 * D03 「위치」 칸(PLAN 596-2 · 요구 463-3~6 · 19) — 지금 기준점 주소 한 줄 · 주소 · 장소 찾기
* (서버 VWorld `searchPlaces` · 부품 `createFindList` · Enter = 찾기 · ↑ ↓ Enter = 고름) · * (서버 VWorld `searchPlaces` · 부품 `createFindList` · Enter = 찾기 · ↑ ↓ Enter = 고름) ·
* 알림 한 줄(받기 실패 · 끊김 · 기준점 없음 · 못 불러옴만) + [다시 받기] · [임도망 올리기]. * 알림 한 줄(받기 실패 · 끊김 · 기준점 없음 · 못 불러옴만) + [다시 받기] · [임도망 올리기] + 올린 임도망 목록
* (`D03_PlanAnalysis_UI_RoadList.ts` · 605-2).
* 고름 = 페이지가 받기 물음(멂 · 다른 TM 띠) → 기준점 캐시 → 지도 이동 → 둘레 받기 · [저장] 때 정본(등록과 한 칸). * 고름 = 페이지가 받기 물음(멂 · 다른 TM 띠) → 기준점 캐시 → 지도 이동 → 둘레 받기 · [저장] 때 정본(등록과 한 칸).
* ========================================================================== */ * ========================================================================== */
@@ -12,6 +13,7 @@ import { createFindList, type FindList, type FindRow } from "@ui/ui_template_fin
import { t } from "@ui/ui_template_locale"; import { t } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text"; import { createText } from "@ui/ui_template_text";
import { searchPlaces, type PlaceHit } from "./D03_PlanAnalysis_Api"; import { searchPlaces, type PlaceHit } from "./D03_PlanAnalysis_Api";
import { createRoadList, type RoadListParams } from "./D03_PlanAnalysis_UI_RoadList";
/** 찾기 글 최소 길이(서버 VWorld 한 글자 = 쓸모없는 후보). */ /** 찾기 글 최소 길이(서버 VWorld 한 글자 = 쓸모없는 후보). */
const MIN_QUERY = 2; const MIN_QUERY = 2;
@@ -20,7 +22,10 @@ export interface PlanSiteParams {
/** 후보 고름 — 받기 · 캐시 · 이동은 페이지. */ /** 후보 고름 — 받기 · 캐시 · 이동은 페이지. */
onPick: (hit: PlaceHit) => void; onPick: (hit: PlaceHit) => void;
onRetry: () => void; onRetry: () => void;
onUpload: (files: File[]) => void; /** 올림(끝나면 목록을 다시 읽음). */
onUpload: (files: File[]) => Promise<unknown> | void;
/** 올린 임도망 목록(605-2) — 고름 · 지움은 페이지. */
roads: RoadListParams;
} }
export interface PlanSiteState { export interface PlanSiteState {
@@ -104,22 +109,26 @@ export function createPlanSite(params: PlanSiteParams): PlanSite {
size: 28, size: 28,
onClick: params.onRetry, onClick: params.onRetry,
}); });
const roads = createRoadList(params.roads);
const upload = createButton({ const upload = createButton({
label: "임도망 올리기", label: "임도망 올리기",
variant: "ghost", variant: "ghost",
size: 28, size: 28,
onClick: () => 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 까지)"; upload.title = "임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)";
return { return {
parts: [address, find.root, note, retry, upload], parts: [address, find.root, note, retry, upload, roads.root],
render(state) { render(state) {
address.textContent = state.address ?? ""; address.textContent = state.address ?? "";
address.hidden = !state.address; address.hidden = !state.address;
note.textContent = state.note; note.textContent = state.note;
note.hidden = !state.note; note.hidden = !state.note;
find.root.hidden = upload.hidden = !state.editable; find.root.hidden = upload.hidden = !state.editable;
roads.setEditable(state.editable);
retry.hidden = !state.editable || !state.retry; retry.hidden = !state.editable || !state.retry;
}, },
}; };
@@ -62,8 +62,8 @@ def test_save_roads_files_clip_and_saved(tmp_path) -> None:
assert got["crs"] == ["EPSG:5179"] assert got["crs"] == ["EPSG:5179"]
assert got["collection"]["features"][0]["properties"]["임도명"] == "임도0" # cp949 dbf assert got["collection"]["features"][0]["properties"]["임도명"] == "임도0" # cp949 dbf
assert got["source"]["files"] == [name for name, _ in files] assert got["source"]["files"] == [name for name, _ in files]
assert (tmp_path / fr.FOLDER / fr.SOURCE_ZIP).is_file() assert len(list((tmp_path / fr.FOLDER / fr.BUNDLE_DIR).glob("*.zip"))) == 1
assert not list((tmp_path / fr.FOLDER).glob("*.new")) assert not list((tmp_path / fr.FOLDER).rglob("*.new"))
assert fr.road_layer(tmp_path, BOX, EPSG) == json.loads(json.dumps(got)) # 다시 열면 저장본 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: def test_bad_uploads_keep_old(tmp_path) -> None:
files = _shp_files(tmp_path / "shp", LINES) files = _shp_files(tmp_path / "shp", LINES)
first = fr.save_roads(tmp_path, files, BOX, EPSG) 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 = [ bad = [
([("a.zip", b"not a zip")], "zip 파일을 열지"), ([("a.zip", b"not a zip")], "zip 파일을 열지"),
([(n, d) for n, d in files if not n.endswith(".dbf")], ".dbf"), ([(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: for upload, text in bad:
with pytest.raises(ValueError, match=re.escape(text)): with pytest.raises(ValueError, match=re.escape(text)):
fr.save_roads(tmp_path, upload, BOX, EPSG) 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 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: 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) 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: def test_no_upload_is_not_found(tmp_path) -> None:
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
fr.road_layer(tmp_path, BOX, EPSG) 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") router = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_Router.py").read_text("utf-8")
assert fr.MAX_BYTES == 100 * 1024 * 1024 assert fr.MAX_BYTES == 100 * 1024 * 1024
assert "status_code=413" in router and "forest_road.MAX_BYTES" in router 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 "uploadRoads" in page and "uploadPlanForestRoads(" in page
assert "<input" not in page and 'createElement("input")' not in page assert "<input" not in page and 'createElement("input")' not in page
assert paint.count("base: true,") == paint.count("draw: (context, map) =>") # 겹 모두 바탕 assert paint.count("base: true,") == paint.count("draw: (context, map) =>") # 겹 모두 바탕
# 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 (
"<button" not in roads and "<input" not in roads and 'createElement("button")' not in roads
)
assert "createRoadList(" in site and "roads.reload()" in site
assert len((folder / "D03_PlanAnalysis_UI_RoadList.ts").read_text("utf-8").splitlines()) <= 700
assert "임도 짐작" in legend and "올린 임도망도" in legend # 범례 구분 assert "임도 짐작" in legend and "올린 임도망도" in legend # 범례 구분
assert "이 시도 자료 없음" in legend assert "이 시도 자료 없음" in legend
for name in ( for name in (
+1 -1
View File
@@ -583,7 +583,7 @@ html.is-bm-route .m01lab-src__body * {
--draw-map-fault: #f43f5e; /* 단층 */ --draw-map-fault: #f43f5e; /* 단층 */
--draw-map-lineament: #c4b5fd; /* 지질구조선(점선) */ --draw-map-lineament: #c4b5fd; /* 지질구조선(점선) */
--draw-map-zero-order: #7dd3fc; /* 0차곡(지형 추정 골짜기 머리 · 점선) */ --draw-map-zero-order: #7dd3fc; /* 0차곡(지형 추정 골짜기 머리 · 점선) */
--draw-map-forest-road-file: #ea580c; /* 올린 임도망도(실선 · 짐작 = 주황 점선) */ --draw-map-forest-road-file: #ff2fd0; /* 올린 임도망도(실선 · 검은 테 · 위성 위 밝은 분홍 · 짐작 = 주황 점선) */
--draw-map-landslide-zone: #e11d48; /* 산사태취약지역 점 · 필지 테두리 */ --draw-map-landslide-zone: #e11d48; /* 산사태취약지역 점 · 필지 테두리 */
--draw-map-sketch: #ffe94d; /* 지도 위 그림(테마 안 따름 · 검은 테와 함께) — 긋기 미리보기 · 그리는 선 · 고름 강조 · 마커 */ --draw-map-sketch: #ffe94d; /* 지도 위 그림(테마 안 따름 · 검은 테와 함께) — 긋기 미리보기 · 그리는 선 · 고름 강조 · 마커 */
--draw-map-measure: #ffffff; /* 거리재기 */ --draw-map-measure: #ffffff; /* 거리재기 */