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 }> =>
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,
+138 -25
View File
@@ -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)
+30 -4
View File
@@ -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);
},
},
{
+84 -5
View File
@@ -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),
+36 -4
View File
@@ -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();
+9 -1
View File
@@ -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();
},
};
}
+13 -4
View File
@@ -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 (
+1 -1
View File
@@ -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; /* 거리재기 */