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:
@@ -251,9 +251,25 @@ export const fetchPlanZeroOrder = (projectId: string): Promise<PlanZeroOrder> =>
|
||||
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<PlanForestRoads> =>
|
||||
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 묶음) — 못 읽으면 서버 글 그대로 오류. */
|
||||
export async function uploadPlanForestRoads(
|
||||
projectId: string,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(...)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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<PlanLayerKey, boolean>;
|
||||
data: Partial<Prepared>;
|
||||
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);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<void>;
|
||||
/** 임도망도 올리기(왼쪽 「위치」 칸) — 받으면 그 겹을 켬 · 못 받으면 알림 + null. */
|
||||
uploadRoads: (files: File[]) => Promise<PlanForestRoads | null>;
|
||||
/** 올린 묶음 하나 지움(605-1) — 지도에서 바로 빠짐 · 못 지우면 알림 + 거짓. */
|
||||
deleteRoad: (bundle: string) => Promise<boolean>;
|
||||
/** 올린 묶음 · 선 고름(605-2) — 지도 강조(꺼 있던 겹은 켬) · null = 강조 없앰. */
|
||||
pickRoad: (pick: RoadPick | null) => void;
|
||||
/** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */
|
||||
state: () => {
|
||||
on: Record<PlanLayerKey, boolean>;
|
||||
@@ -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<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 sheetLayer = (layer: string, extra = ""): Promise<GeoJsonCollection> =>
|
||||
@@ -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),
|
||||
|
||||
@@ -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<PlanLayerKey>({
|
||||
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();
|
||||
|
||||
@@ -232,7 +232,15 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
[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);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<unknown> | 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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 "<input" not in page and 'createElement("input")' not in page
|
||||
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
|
||||
for name in (
|
||||
|
||||
@@ -583,7 +583,7 @@ 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-forest-road-file: #ff2fd0; /* 올린 임도망도(실선 · 검은 테 · 위성 위 밝은 분홍 · 짐작 = 주황 점선) */
|
||||
--draw-map-landslide-zone: #e11d48; /* 산사태취약지역 점 · 필지 테두리 */
|
||||
--draw-map-sketch: #ffe94d; /* 지도 위 그림(테마 안 따름 · 검은 테와 함께) — 긋기 미리보기 · 그리는 선 · 고름 강조 · 마커 */
|
||||
--draw-map-measure: #ffffff; /* 거리재기 */
|
||||
|
||||
Reference in New Issue
Block a user