Merge remote-tracking branch 'origin/dev' into sub_laptop_3

This commit is contained in:
2026-10-28 07:01:46 +09:00
13 changed files with 779 additions and 1 deletions
@@ -33,6 +33,7 @@ SHEET_LAYERS: dict[str, tuple[str, str]] = {
"유수방향": ("N3P", "E0042326"),
"기준점": ("N3P", "H0020000"),
"지명": ("N3P", "H0040000"),
"도로중심선": ("N3L", "A0020000"),
}
# 산출 파일 접두사 (기존 processed/의 국가 GIS geojson과 구분)
+259
View File
@@ -0,0 +1,259 @@
"""D 노선분석 지도 겹 — 도엽 겹 · 지적도 · 급경사 그림 (요구 395-2 · PLAN 569-1).
- 도엽 겹(하천중심선 · 도로중심선 …) = 지형 받기 병합본 · 없으면 지난 병합 도엽에서 그 겹만 풀어 냄
- 지적도 = 브이월드 WFS LX맵(`lt_c_landinfobasemap`) · B04 `request_vworld_wfs` 를 1 km 조각으로 ·
한 번 1,000 필지 한도 → 꽉 차면 넷으로 다시 쪼갬 · `pnu` 로 겹친 필지 거름 ·
저장 = `processed/연속지적도_bounds.geojson`(B04 `/geojson?layer=지적도` 자리) + 곁 열쇠
- 급경사 = 평활 안 한 사업 DEM(`models_plan/dtm_sheet_<방법>.npz`) · E07 `slope_rad` ·
15° · 35° · 40° 단계 PNG · 파생 저장본(열쇠 = 격자 지문)
계산 자리 = ③ 서버 단독.
"""
from __future__ import annotations
import base64
import io
import json
import logging
import math
import tempfile
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any
import numpy as np
from B04_PreProcess.B04_PreProcess_Engine_GisVector import request_vworld_wfs
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import SHEET_LAYERS, parse_and_merge_sheets
from B04_PreProcess.B04_PreProcess_Engine_SheetStore import get_project_map_sheets_dir
from common_util.common_util_derived_store import (
derived_key,
file_stamp,
read_derived,
write_derived,
)
from common_util.common_util_json import atomic_write_json
from config.config_system_terrain import SHEET_SURFACE_DEFAULT_METHOD
logger = logging.getLogger(__name__)
Box = tuple[float, float, float, float] # x0, y0, x1, y1(작업 좌표계 m)
CADASTRAL_TYPENAME = "lt_c_landinfobasemap"
CADASTRAL_FILE = "연속지적도_bounds.geojson"
CADASTRAL_KEY = "연속지적도_bounds.key.json"
TILE_M = 1000.0
WFS_LIMIT = 1000
MAX_SPLIT = 3 # 1 km → 125 m 까지 쪼갬
WFS_WORKERS = 4
SLOPE_KIND = "plan_slope"
SLOPE_VERSION = 1
# (아래 °, 위 °, 색 RGBA) — 임도망_편성 완 · 중 · 급경사지 · 타당성평가 35° · 노선_배치 40° 연속
SLOPE_BANDS = (
(15.0, 35.0, (250, 204, 21, 110)),
(35.0, 40.0, (249, 115, 22, 140)),
(40.0, 90.0, (220, 38, 38, 160)),
)
MAX_SIDE_PX = 4096 # ponytail: 한 변 넘으면 솎음 · 넓은 사업은 조각 그림으로
def _read_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
# ── 도엽 겹 ─────────────────────────────────────────────
def ensure_sheet_layer(project_root: Path, key: str) -> Path:
"""`processed/도엽_{key}.geojson` — 없으면 지난 병합 도엽에서 그 겹만 풀어 냄(0 건 = 빈 것)."""
if key not in SHEET_LAYERS:
raise KeyError(key)
processed = project_root / "B04_PreProcess" / "processed"
path = processed / f"도엽_{key}.geojson"
if path.is_file():
return path
sheets = (_read_json(processed / "sheet_merge.key.json") or {}).get("sheets") or []
if not sheets:
raise FileNotFoundError("병합한 도엽이 없습니다 — 지형 받기 뒤 보입니다.")
parse_and_merge_sheets(get_project_map_sheets_dir(project_root), sheets, processed, [key])
if not path.is_file():
atomic_write_json(path, {"type": "FeatureCollection", "features": []})
return path
# ── 지적도 ──────────────────────────────────────────────
def tiles(box: Box, size: float = TILE_M) -> list[Box]:
"""상자를 `size` 칸으로 쪼갬(끝 칸은 남은 만큼)."""
x0, y0, x1, y1 = box
nx, ny = max(1, math.ceil((x1 - x0) / size)), max(1, math.ceil((y1 - y0) / size))
return [
(x0 + i * size, y0 + j * size, min(x1, x0 + (i + 1) * size), min(y1, y0 + (j + 1) * size))
for j in range(ny)
for i in range(nx)
]
def quarter(box: Box) -> list[Box]:
x0, y0, x1, y1 = box
xm, ym = (x0 + x1) / 2, (y0 + y1) / 2
return [(x0, y0, xm, ym), (xm, y0, x1, ym), (x0, ym, xm, y1), (xm, ym, x1, y1)]
def _lonlat_bounds(box: Box, epsg: int) -> dict[str, float]:
from common_util.common_util_crs import tm_to_lonlat
x0, y0, x1, y1 = box
ring = [tm_to_lonlat(x, y, epsg) for x, y in ((x0, y0), (x1, y0), (x1, y1), (x0, y1))]
lons, lats = [p[0] for p in ring], [p[1] for p in ring]
return {"min_lon": min(lons), "min_lat": min(lats), "max_lon": max(lons), "max_lat": max(lats)}
def _wfs(box: Box, epsg: int) -> list[dict[str, Any]] | None:
"""조각 하나 — B04 WFS 받기(실패 = 파일 없음 · 키 값은 남기지 않음). None = 실패."""
with tempfile.TemporaryDirectory() as folder:
request_vworld_wfs(
CADASTRAL_TYPENAME, "tile.geojson", _lonlat_bounds(box, epsg), Path(folder)
)
data = _read_json(Path(folder) / "tile.geojson")
if not isinstance(data, dict) or not isinstance(data.get("features"), list):
return None
return data["features"]
def fetch_tile(box: Box, epsg: int, depth: int = 0) -> tuple[list[dict[str, Any]], int]:
"""(필지, 실패 조각 수) — 1,000 꽉 차면 넷으로 다시 쪼갬."""
features = _wfs(box, epsg)
if features is None:
return [], 1
if len(features) < WFS_LIMIT or depth >= MAX_SPLIT:
return features, 0
out: list[dict[str, Any]] = []
failed = 0
for part in quarter(box):
got, bad = fetch_tile(part, epsg, depth + 1)
out.extend(got)
failed += bad
return out, failed
def dedup_parcels(features: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""조각 경계에 걸친 필지 — `pnu`(없으면 지물 id) 첫 것만."""
seen: set[str] = set()
out = []
for feature in features:
props = feature.get("properties") or {}
key = str(props.get("pnu") or feature.get("id") or json.dumps(feature.get("geometry")))
if key not in seen:
seen.add(key)
out.append(feature)
return out
def _covers(have: Any, box: Box) -> bool:
try:
return have[0] <= box[0] and have[1] <= box[1] and have[2] >= box[2] and have[3] >= box[3]
except (TypeError, IndexError):
return False
def ensure_cadastral(processed: Path, box: Box, epsg: int) -> dict[str, Any]:
"""지적도 — 열쇠 상자가 덮고 실패 조각 없으면 그대로 · 아니면 상자 통째로 다시 받음."""
path, key_path = processed / CADASTRAL_FILE, processed / CADASTRAL_KEY
key = _read_json(key_path) or {}
if path.is_file() and not key.get("failed") and _covers(key.get("box"), box):
return {"path": path, "fetched": False, "count": key.get("count"), "failed": 0}
parts = tiles(box)
with ThreadPoolExecutor(max_workers=WFS_WORKERS) as pool:
results = list(pool.map(lambda part: fetch_tile(part, epsg), parts))
failed = sum(bad for _, bad in results)
if failed == len(parts):
raise RuntimeError(f"지적도를 받지 못했습니다 — 브이월드 WFS 조각 {failed}개 실패")
features = dedup_parcels([f for got, _ in results for f in got])
processed.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, {"type": "FeatureCollection", "features": features})
atomic_write_json(
key_path,
{
"box": [round(v, 3) for v in box],
"epsg": int(epsg),
"count": len(features),
"failed": failed,
},
)
logger.info("D 지적도 받기: 조각 %d · 실패 %d · 필지 %d", len(parts), failed, len(features))
return {"path": path, "fetched": True, "count": len(features), "failed": failed}
# ── 급경사 ──────────────────────────────────────────────
def slope_bands_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict[str, Any]:
"""격자(행 = 남 → 북) → 단계 PNG(위 = 북 · 15° 미만 · 빈 칸 투명) + 단계 칸 비율."""
from PIL import Image
from E07_StabArea.E07_StabArea_Engine import slope_rad
stride = max(1, math.ceil(max(z.shape) / MAX_SIDE_PX))
grid = np.where(valid, np.asarray(z, float), np.nan)[::stride, ::stride]
deg = np.degrees(slope_rad(grid, cell_m * stride))
known = np.isfinite(deg)
index = np.zeros(deg.shape, np.uint8)
for i, (low, high, _) in enumerate(SLOPE_BANDS, start=1):
index[known & (deg >= low) & (deg < high)] = i
total = max(1, int(known.sum()))
image = Image.fromarray(np.flipud(index), mode="P")
palette = [0, 0, 0] + [c for _, _, rgba in SLOPE_BANDS for c in rgba[:3]]
image.putpalette(palette + [0] * (768 - len(palette)))
alpha = bytes([0] + [rgba[3] for _, _, rgba in SLOPE_BANDS])
buffer = io.BytesIO()
image.save(buffer, format="PNG", optimize=True, transparency=alpha)
return {
"width": image.width,
"height": image.height,
"image": "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii"),
"bands": [
{
"min_deg": low,
"max_deg": high,
"color": "#{:02x}{:02x}{:02x}".format(*rgba[:3]),
"ratio": round(int((index == i).sum()) / total, 4),
}
for i, (low, high, rgba) in enumerate(SLOPE_BANDS, start=1)
],
}
def slope_layer(project_root: Path, models_dir: Path, epsg: int) -> dict[str, Any]:
"""급경사 겹 — 지문(격자 파일 · 단계 · 판) 같으면 저장본 그대로."""
from common_util.common_util_crs import tm_to_lonlat
npz = models_dir / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz"
stamp = file_stamp(npz)
if stamp is None:
raise FileNotFoundError("사업 지형면이 없습니다 — 지형 받기 뒤 보입니다.")
key = derived_key(
SLOPE_KIND, SLOPE_VERSION, npz.name, stamp, int(epsg), SLOPE_BANDS, MAX_SIDE_PX
)
saved = read_derived(project_root, SLOPE_KIND, key)
if saved is not None:
return saved["value"]
with np.load(npz) as dem:
x, y = np.asarray(dem["x"], float), np.asarray(dem["y"], float)
cell = float(dem["resolution"][0]) if "resolution" in dem.files else float(x[1] - x[0])
value = slope_bands_png(dem["z"], np.asarray(dem["valid_mask"], bool), cell)
half = cell / 2
box = (x[0] - half, y[0] - half, x[-1] + half, y[-1] + half)
value["box_m"] = [float(v) for v in box]
value["box"] = [
list(tm_to_lonlat(px, py, epsg))
for px, py in ((box[0], box[1]), (box[2], box[1]), (box[2], box[3]), (box[0], box[3]))
]
write_derived(project_root, SLOPE_KIND, key, value)
return value
+54 -1
View File
@@ -3,6 +3,7 @@
- `GET /api/projects/{id}/plan/terrain/extent` — 받기 전 범위(상자 · 도엽 · 칸 수 · 받은 범위)
- `POST …/plan/terrain` — 사업 전체 지형 한 벌 받기(뒤에서 · 누를 때만) · 끝나면 `surface_models` 행
- `GET …/plan/progress` — 일의 진행률
- `GET …/plan/layers/sheet?key=` · `…/cadastral` · `…/raster?kind=slope` — 지도 겹(PLAN 569-1)
범위 = 전체 시점 · 종점(D02 정본 `load_base`) + 그은 점(`extra`) 상자 + 여유.
노선 정본(손 그은 선)은
@@ -18,13 +19,15 @@ from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from D02_PlanBase import D02_PlanBase_Router as base
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
from D03_PlanAnalysis import D03_PlanAnalysis_Terrain as terrain
logger = logging.getLogger(__name__)
@@ -153,3 +156,53 @@ async def plan_progress(project_id: UUID) -> dict[str, Any]:
"status": "success",
**{kind: dict(_JOBS.get((str(project_id), kind)) or idle) for kind in KINDS},
}
async def _layer_root(project_id: UUID) -> tuple[Path, int, terrain.Box]:
"""(실경로, 좌표계, 받은 범위) — 지형 받기 전이면 404."""
root, epsg, _ = await _plan_ends(project_id)
box = terrain.received_box(root)
if box is None:
raise HTTPException(status_code=404, detail="지형 받기 뒤 보입니다.")
return root, epsg, box
@router.get("/{project_id}/plan/layers/sheet", response_model=None)
async def plan_layer_sheet(project_id: UUID, key: str, request: Request) -> Response:
"""도엽 겹 GeoJSON(하천중심선 · 도로중심선 …) — 없으면 지난 병합 도엽에서 풀어 냄."""
from B04_PreProcess.B04_PreProcess_Router_GIS import _display_copy
root, _, _ = await _layer_root(project_id)
try:
path = await asyncio.to_thread(layers.ensure_sheet_layer, root, key)
except KeyError as error:
raise HTTPException(status_code=400, detail=f"없는 도엽 겹: {key}") from error
except FileNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
display = await asyncio.to_thread(_display_copy, path)
return cached_file_response(request, display, "application/geo+json")
@router.get("/{project_id}/plan/layers/cadastral", response_model=None)
async def plan_layer_cadastral(project_id: UUID, request: Request) -> Response:
"""지적도 GeoJSON(브이월드 LX맵 · 참고용) — 받은 범위를 덮지 않으면 지금 받음."""
root, epsg, box = await _layer_root(project_id)
processed = root / terrain.STAGE / "processed"
try:
got = await asyncio.to_thread(layers.ensure_cadastral, processed, box, epsg)
except RuntimeError as error:
raise HTTPException(status_code=502, detail=str(error)) from error
return cached_file_response(request, got["path"], "application/geo+json")
@router.get("/{project_id}/plan/layers/raster", response_model=None)
async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str, Any]:
"""급경사 단계 그림(15° · 35° · 40°) — 격자 지문 같으면 저장본."""
if kind != "slope":
raise HTTPException(status_code=400, detail=f"없는 그림 겹: {kind}")
root, epsg, _ = await _layer_root(project_id)
try:
value = await asyncio.to_thread(layers.slope_layer, root, terrain.models_dir_of(root), epsg)
except FileNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
return {"status": "success", "kind": kind, **value}
@@ -6,6 +6,7 @@
- 도엽 = 범위 상자에 닿는 도엽 모두 → `ensure_sheets`(없는 것만 · prune 없음)
- 병합 = 지난번 병합 도엽 ∪ 이번 도엽(옛 도엽 유지 · 열쇠 같으면 건너뜀)
- 위성 = 범위 도엽 도곽 ∪ 옛 위성 범위(덮으면 건너뜀)
- 지적도 = 범위 상자 · 브이월드 WFS 1 km 조각(`D03_PlanAnalysis_Layers` · 실패해도 계속)
- DEM = 한 벌 `models_plan/`(연도는 D03 나눔 뒤에 정해짐) · 받은 범위 = `plan_terrain.json`
- 작업 좌표계 = 지점 epsg · `B03_FileInput/input/prj/plan.prj` 한 장(B04 · B05 좌표계 창구가 읽음)
- 받기 전 범위 보기 = `plan_extent`(내려받기 없음) · 받기는 사용자가 누를 때만
@@ -42,6 +43,7 @@ from config.config_system_terrain import (
SHEET_SURFACE_DEFAULT_METHOD,
SHEET_SURFACE_MARGIN_M,
)
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
logger = logging.getLogger(__name__)
@@ -254,6 +256,12 @@ def ensure_plan_terrain(
merged = _merge_sheets(processed, store, list(got["available"]))
report(60, "위성 지도 받는 중")
satellite = _satellite(processed, prj, [(p[1], p[0]) for p in box_ring(box, epsg)], epsg)
report(62, "지적도 받는 중")
try:
cadastral = layers.ensure_cadastral(processed, box, epsg)["count"]
except Exception as exc: # noqa: BLE001 — 지적도는 겹 · 지형 받기를 막지 않음(켤 때 다시)
logger.warning("D 지적도 받기 실패: %s", exc)
cadastral = None
report(65, "사업 지형면 만드는 중")
models_dir = models_dir_of(project_root)
@@ -287,6 +295,7 @@ def ensure_plan_terrain(
"merged": merged,
},
"satellite": satellite,
"cadastral": cadastral,
"box_m": list(box),
"models": models,
}
@@ -54,6 +54,7 @@ def fake(tmp_path, monkeypatch):
monkeypatch.setattr(terrain, "parse_and_merge_sheets", merge)
monkeypatch.setattr(terrain, "download_vworld_satellite_map", satellite)
monkeypatch.setattr(terrain, "build_sheet_surface_model", dem)
monkeypatch.setattr(terrain.layers, "ensure_cadastral", lambda *a: {"count": 0}) # 569-1
return tmp_path, seen
+1
View File
@@ -25,6 +25,7 @@ PARTS = {
"float-panel": ("ui_template_float_panel", "createFloatPanel", "ui-float-panel", (32,)),
"drawer": ("ui_template_side_panels", "createSidePanels", "ui-side-panels", ()),
"toolbar": ("ui_template_toolbar", "createToolbar", "ui-toolbar", (24, 28, 32)),
"map-tools": ("ui_template_map_tools", "createMapTools", "ui-map-tools", ()),
"steps": ("ui_template_workflow_layout", "createStepBar", "ui-workflow-layout__step", (28, 40)),
"panel-head": ("ui_template_panel_head", "createPanelHead", "ui-panel-head", (32,)),
"list-row": ("ui_template_list_row", "createListRow", "ui-list-row", (28, 32, 36)),
+81
View File
@@ -0,0 +1,81 @@
"""568-1 지도 도구줄 부품 지킴 시험 — 화면 확인은 부품 모음(`#/b01-account?view=ui-kit`) · ORCA.
· 단추 목록의 아이콘이 모두 아이콘 묶음에 있음 · 새 아이콘 여덟(도구 넷 · 겹 넷)
· 켬끔 단추 = aria-pressed · [?] = data-act="hint-toggle" + aria-expanded · 안내 판 기본 접힘
./venv/Scripts/python.exe -m pytest resources/tester/test_568_1_map_tools.py -q
"""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
UI = ROOT / "ui_template"
TS = (UI / "ui_template_map_tools.ts").read_text(encoding="utf-8")
ICONS = (UI / "ui_template_icons.ts").read_text(encoding="utf-8")
CSS = (UI / "ui_template_map_tools.css").read_text(encoding="utf-8")
def _icon_names() -> set[str]:
body = ICONS[ICONS.index("const PATHS") : ICONS.index("} as const")]
return set(re.findall(r"^ (\w+):", body, re.M))
def test_새_아이콘_여덟():
names = _icon_names()
for n in (
"ripple",
"tallymarks",
"percentage",
"magnet",
"satellite",
"droplet",
"borderAll",
"angle",
):
assert n in names, n
def test_단추_목록_아이콘_차례():
table = TS[TS.index("export const MAP_TOOLS") : TS.index("satisfies")]
rows = re.findall(
r'^ "?([\w-]+)"?: \{ icon: "(\w+)", title: "[^"]+"(, toggle: true)? \}', table, re.M
)
acts = [a for a, _, _ in rows]
assert acts == [
"undo",
"redo",
"view3d",
"rotate-ccw",
"rotate-cw",
"contour-minor",
"stations",
"slopes",
"footprint",
"measure",
"station-snap",
]
names = _icon_names()
assert all(icon in names for _, icon, _ in rows)
toggles = {a for a, _, t in rows if t}
assert toggles == {
"contour-minor",
"stations",
"slopes",
"footprint",
"measure",
"station-snap",
}
def test_켬끔_안내_판():
assert "pressed: tool.toggle ? (opts.pressed ?? false) : undefined" in TS
assert "button.dataset.act = act;" in TS
assert 'toggle.dataset.act = "hint-toggle";' in TS
assert 'toggle.setAttribute("aria-expanded", String(open));' in TS
assert "setOpen(opts.hint.open ?? false);" in TS # 기본 접힘
hint = CSS[CSS.index(".ui-map-tools__hint {") :]
assert "display: none;" in hint[: hint.index("}")]
assert "display: grid;" in CSS[CSS.index(".ui-map-tools__hint.is-open {") :]
assert "pointer-events: none;" in CSS # 빈 자리 · 글판은 지도 조작 통과
+181
View File
@@ -0,0 +1,181 @@
"""D 노선분석 지도 겹(PLAN 569-1) — 도엽 겹 풀어 냄 · 지적도 1 km 조각 · 1,000 꽉 차면 넷 ·
겹친 필지 거름 · 급경사 15° · 35° · 40° 단계 · 격자 지문 같으면 다시 안 셈.
./venv/Scripts/python.exe -m pytest resources/tester/test_569_1_plan_layers.py -q
"""
import base64
import io
import itertools
import json
import os
from pathlib import Path
import numpy as np
import pytest
from PIL import Image
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import SHEET_LAYERS
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
EPSG = 5186
BOX = (230000.0, 450000.0, 234700.0, 452500.0) # 4.7 × 2.5 km
# ── 도엽 겹 ──
def test_road_layer_listed() -> None:
assert SHEET_LAYERS["도로중심선"] == ("N3L", "A0020000")
assert SHEET_LAYERS["하천중심선"] == ("N3L", "E0020000")
def test_sheet_layer_unpacks_once(tmp_path, monkeypatch) -> None:
processed = tmp_path / "B04_PreProcess" / "processed"
processed.mkdir(parents=True)
calls = []
def merge(store, sheets, out, keys):
calls.append((list(sheets), keys)) # 지물 0 건 = 파일 안 씀
monkeypatch.setattr(layers, "parse_and_merge_sheets", merge)
with pytest.raises(FileNotFoundError):
layers.ensure_sheet_layer(tmp_path, "도로중심선")
with pytest.raises(KeyError):
layers.ensure_sheet_layer(tmp_path, "없는겹")
(processed / "sheet_merge.key.json").write_text(
json.dumps({"sheets": ["36712088", "36712089"], "parser": 2}), "utf-8"
)
path = layers.ensure_sheet_layer(tmp_path, "도로중심선")
assert calls == [(["36712088", "36712089"], ["도로중심선"])]
assert json.loads(path.read_text("utf-8"))["features"] == []
layers.ensure_sheet_layer(tmp_path, "도로중심선")
assert len(calls) == 1 # 있으면 그대로
# ── 지적도 ──
def test_tiles_cover_box() -> None:
parts = layers.tiles(BOX)
assert len(parts) == 15
assert sum((b[2] - b[0]) * (b[3] - b[1]) for b in parts) == pytest.approx(4700 * 2500)
assert max(b[2] for b in parts) == BOX[2] and max(b[3] for b in parts) == BOX[3]
assert len(layers.quarter(parts[0])) == 4
class FakeWfs:
"""조각 너비가 `full_deg` 보다 넓으면 1,000 필지(꽉 참) · 아니면 셋(하나는 이웃과 같은 pnu) ·
처음 `fail` 번은 실패(B04 꼴 — 파일 없음)."""
def __init__(self, full_deg: float = 1.0, fail: int = 0) -> None:
self.full_deg, self.fail, self._count = full_deg, fail, itertools.count(1)
self.calls = 0
def __call__(self, typename, filename, bounds, output_dir) -> None:
assert typename == "lt_c_landinfobasemap"
self.calls = next(self._count) # 조각을 여러 줄기로 받음 — count 는 한 번에 하나
if self.calls <= self.fail:
return
width = bounds["max_lon"] - bounds["min_lon"]
tag = f"{bounds['min_lon']:.5f},{bounds['min_lat']:.5f}"
n = 1000 if width > self.full_deg else 3
feats = [{"id": f"{tag}/{i}", "properties": {"pnu": f"{tag}/{i}"}} for i in range(n)]
feats[-1]["properties"]["pnu"] = "SHARED"
body = {"type": "FeatureCollection", "features": feats}
(Path(output_dir) / filename).write_text(json.dumps(body), "utf-8")
def test_full_tile_splits_into_four(monkeypatch) -> None:
fake = FakeWfs(full_deg=0.008) # 1 km 칸 ≈ 0.011° 꽉 참 · 500 m 칸 = 셋
monkeypatch.setattr(layers, "request_vworld_wfs", fake)
got, failed = layers.fetch_tile((230000.0, 450000.0, 231000.0, 451000.0), EPSG)
assert fake.calls == 5 and failed == 0 and len(got) == 12
def test_dedup_by_pnu() -> None:
feats = [{"properties": {"pnu": "1"}}, {"properties": {"pnu": "1"}}, {"id": "x"}]
assert len(layers.dedup_parcels(feats)) == 2
def test_cadastral_fetch_once_then_grow(tmp_path, monkeypatch) -> None:
fake = FakeWfs()
monkeypatch.setattr(layers, "request_vworld_wfs", fake)
got = layers.ensure_cadastral(tmp_path, BOX, EPSG)
assert got["fetched"] and fake.calls == 15 and got["failed"] == 0
assert got["count"] == 15 * 2 + 1 # 조각마다 둘 + 같은 pnu 하나
saved = json.loads((tmp_path / layers.CADASTRAL_FILE).read_text("utf-8"))
assert len(saved["features"]) == got["count"]
again = layers.ensure_cadastral(tmp_path, (231000.0, 450500.0, 233000.0, 452000.0), EPSG)
assert not again["fetched"] and fake.calls == 15 # 덮으면 다시 안 받음
layers.ensure_cadastral(tmp_path, (*BOX[:2], BOX[2] + 500, BOX[3]), EPSG)
assert fake.calls == 15 + 18 # 넓어지면 상자 통째로 다시(5.2 km = 6 × 3 조각)
def test_cadastral_failures(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(layers, "request_vworld_wfs", FakeWfs(fail=99))
with pytest.raises(RuntimeError):
layers.ensure_cadastral(tmp_path, BOX, EPSG)
assert not (tmp_path / layers.CADASTRAL_FILE).exists()
monkeypatch.setattr(layers, "request_vworld_wfs", FakeWfs(fail=1))
first = layers.ensure_cadastral(tmp_path, BOX, EPSG)
assert first["failed"] == 1 and (tmp_path / layers.CADASTRAL_FILE).exists()
again = FakeWfs()
monkeypatch.setattr(layers, "request_vworld_wfs", again)
assert layers.ensure_cadastral(tmp_path, BOX, EPSG)["fetched"] and again.calls == 15
# ── 급경사 ──
def _plane(deg_south: float, deg_north: float, n: int = 40, cell: float = 2.0) -> np.ndarray:
"""남쪽 반 = deg_south · 북쪽 반 = deg_north 경사(동쪽으로 오름)."""
x = np.arange(n) * cell
z = np.empty((n, n))
z[: n // 2] = x * np.tan(np.radians(deg_south))
z[n // 2 :] = x * np.tan(np.radians(deg_north))
return z
def _indices(value) -> np.ndarray:
raw = base64.b64decode(value["image"].split(",", 1)[1])
return np.asarray(Image.open(io.BytesIO(raw)))
def test_slope_bands_and_north_up() -> None:
z = _plane(10.0, 37.0)
value = layers.slope_bands_png(z, np.ones(z.shape, bool), 2.0)
ratios = [b["ratio"] for b in value["bands"]]
assert [b["min_deg"] for b in value["bands"]] == [15.0, 35.0, 40.0]
assert ratios[0] < 0.1 and 0.4 < ratios[1] < 0.6 and ratios[2] < 0.1 # 경계 줄만
index = _indices(value)
assert value["width"] == value["height"] == 40
assert (index[5, 5:35] == 2).all() and (index[-5, 5:35] == 0).all() # 위 = 북 = 37°
steep = layers.slope_bands_png(_plane(45.0, 20.0), np.ones((40, 40), bool), 2.0)
assert steep["bands"][2]["ratio"] > 0.4 and steep["bands"][0]["ratio"] > 0.4
hole = np.ones(z.shape, bool)
hole[25:30, 10:20] = False
assert (_indices(layers.slope_bands_png(z, hole, 2.0))[-29:-24, 10:20] == 0).all()
def test_slope_layer_fingerprint(tmp_path, monkeypatch) -> None:
models = tmp_path / "B04_PreProcess" / "models_plan"
models.mkdir(parents=True)
npz = models / "dtm_sheet_laplace.npz"
z = _plane(37.0, 37.0)
x = 200000.0 + np.arange(40) * 2.0
y = 450000.0 + np.arange(40) * 2.0
np.savez(npz, x=x, y=y, z=z, valid_mask=np.ones(z.shape, bool), resolution=[2.0])
counted = []
real = layers.slope_bands_png
monkeypatch.setattr(layers, "slope_bands_png", lambda *a: counted.append(1) or real(*a))
first = layers.slope_layer(tmp_path, models, EPSG)
assert first["box_m"] == [199999.0, 449999.0, 200079.0, 450079.0] and len(first["box"]) == 4
assert layers.slope_layer(tmp_path, models, EPSG) == first and len(counted) == 1
np.savez(npz, x=x, y=y, z=z * 0.5, valid_mask=np.ones(z.shape, bool), resolution=[2.0])
os.utime(npz, ns=(1, 1))
layers.slope_layer(tmp_path, models, EPSG)
assert len(counted) == 2 # 격자가 바뀌면 다시 셈
(npz).unlink()
with pytest.raises(FileNotFoundError):
layers.slope_layer(tmp_path, models, EPSG)
+15
View File
@@ -123,6 +123,21 @@ const PATHS = {
mail: "M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10|M3 7l9 6l9 -6",
inbox:
"M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12|M4 13h3l3 3h4l3 -3h3",
// 568-1 — 지도 도구(1m 등고선 · 측점 · 기울기 · 측점 스냅)
ripple: "M3 7c3 -2 6 -2 9 0s6 2 9 0|M3 17c3 -2 6 -2 9 0s6 2 9 0|M3 12c3 -2 6 -2 9 0s6 2 9 0",
tallymarks: "M6 5l0 14|M10 5l0 14|M14 5l0 14|M18 5l0 14|M3 17l18 -10",
percentage: "M16 17a1 1 0 1 0 2 0a1 1 0 0 0 -2 0|M6 7a1 1 0 1 0 2 0a1 1 0 0 0 -2 0|M6 18l12 -12",
magnet:
"M4 13v-8a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v8a2 2 0 0 0 6 0v-8a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v8a8 8 0 0 1 -16 0|M4 8l5 0|M15 8l4 0",
// 568-1 — 지도 겹(위성 · 세류선 · 지적도 · 급경사)
satellite:
"M3.707 6.293l2.586 -2.586a1 1 0 0 1 1.414 0l5.586 5.586a1 1 0 0 1 0 1.414l-2.586 2.586a1 1 0 0 1 -1.414 0l-5.586 -5.586a1 1 0 0 1 0 -1.414z|M6 10l-3 3l3 3l3 -3|M10 6l3 -3l3 3l-3 3|M12 12l1.5 1.5|M14.5 17a2.5 2.5 0 0 0 2.5 -2.5|M15 21a6 6 0 0 0 6 -6",
droplet:
"M7.502 19.423c2.602 2.105 6.395 2.105 8.996 0c2.602 -2.105 3.262 -5.708 1.566 -8.546l-4.89 -7.26c-.42 -.625 -1.287 -.803 -1.936 -.397a1.376 1.376 0 0 0 -.41 .397l-4.893 7.26c-1.695 2.838 -1.035 6.441 1.567 8.546z",
borderAll:
"M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z|M4 12l16 0|M12 4l0 16",
angle:
"M21 19h-18l9 -15|M20.615 15.171h.015|M19.515 11.771h.015|M17.715 8.671h.015|M15.415 5.971h.015",
} as const;
export type IconName = keyof typeof PATHS;
+33
View File
@@ -0,0 +1,33 @@
/* 지도 화면 제어 도구줄(568-1) — 도구줄 + 아래 [?] 안내 판 · 자리는 페이지가 줌 · 빈 자리는 지도 조작 통과 */
.ui-map-tools {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-8);
pointer-events: none;
}
.ui-map-tools > .ui-toolbar {
max-width: 100%;
pointer-events: auto;
}
/* 안내 판 — 기본 접힘 · 2열 · 글판은 클릭 통과 */
.ui-map-tools__hint {
display: none;
grid-template-columns: auto auto;
gap: 2px var(--spacing-16);
max-width: 100%;
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent);
border-radius: var(--radius-cards);
background: color-mix(in srgb, var(--color-surface-raised) 72%, transparent);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.ui-map-tools__hint.is-open {
display: grid;
}
+119
View File
@@ -0,0 +1,119 @@
/* =============================================================================
* ui_template_map_tools.ts
* 지도 화면 제어 도구줄(568-1) — 평면 지도 위 아이콘 단추 한 벌 · B05 평면 · D03 평면이 같이 씀.
* 꼴 = 유리 도구줄(`createToolbar` glass · 32) 한 줄 · 묶음 사이 나눔선 · 단추는 모두 아이콘(마우스 올리면 이름)
* [?] 안내 판 = 도구줄 아래 2열 글판(기본 접힘 · 클릭은 지도로 통과)
* 크기 = 단 32 하나(지도 위 도구)
* 상태 = 켬끔 단추는 `aria-pressed`(눌림 꼴) · 막힘 `disabled` · [?] 는 `aria-expanded`
* 쓰는 법 = createMapTools({ label: "평면 도구", groups: [["undo", "redo"], ["rotate-ccw", "rotate-cw", "measure"]],
* hint: { lines: ["휠 = 확대"] } }) → { root, bar, hint, button(act) }
* 페이지 문구가 다르면 `mapToolButton("measure", { title })` 를 묶음에 그대로 끼움 · 고르기 · 칩도 요소로 끼움
* 누름 동작은 페이지가 붙임(`onClick` 또는 `[data-act]` 로 찾아 붙임) · 자리는 페이지 CSS(root)
* 대신하는 옛 변종 = B05 `b05-route__view-controls` 글자 유리 단추 줄 · `b05-routeedit__hint` 안내 판 · D03 `tool()` 지역 함수
* ========================================================================== */
import { createIconButton } from "./ui_template_button";
import type { IconName } from "./ui_template_icons";
import { createToolbar } from "./ui_template_toolbar";
import "./ui_template_map_tools.css";
/** 단추 목록 — 차례 = 도구줄 차례 · toggle = 켬끔 단추(aria-pressed) */
export const MAP_TOOLS = {
undo: { icon: "undo", title: "되돌리기 (Ctrl+Z)" },
redo: { icon: "redo", title: "다시하기 (Ctrl+Y)" },
view3d: { icon: "cube", title: "3D 보기" },
"rotate-ccw": { icon: "rotateCcw", title: "반시계로 돌리기" },
"rotate-cw": { icon: "rotateCw", title: "시계로 돌리기" },
"contour-minor": { icon: "ripple", title: "1m 등고선", toggle: true },
stations: { icon: "tallymarks", title: "측점", toggle: true },
slopes: { icon: "percentage", title: "기울기", toggle: true },
footprint: { icon: "road", title: "절/성토선", toggle: true },
measure: { icon: "ruler", title: "거리 재기", toggle: true },
"station-snap": { icon: "magnet", title: "측점 스냅", toggle: true },
} satisfies Record<string, { icon: IconName; title: string; toggle?: boolean }>;
export type MapToolAct = keyof typeof MAP_TOOLS;
export interface MapToolButtonOptions {
/** 마우스 글 — 없으면 목록 이름 */
title?: string;
/** 켬끔 단추 처음 값(기본 false) */
pressed?: boolean;
disabled?: boolean;
onClick?: (ev: MouseEvent) => void;
}
/** 아이콘 단추 하나(`data-act` = act) */
export function mapToolButton(act: MapToolAct, opts: MapToolButtonOptions = {}): HTMLButtonElement {
const tool: { icon: IconName; title: string; toggle?: boolean } = MAP_TOOLS[act];
const button = createIconButton({
icon: tool.icon,
title: opts.title ?? tool.title,
size: 32,
pressed: tool.toggle ? (opts.pressed ?? false) : undefined,
disabled: opts.disabled,
onClick: opts.onClick,
});
button.dataset.act = act;
return button;
}
export interface MapToolsOptions {
/** 묶음들 — 사이에 나눔선 · act 이름 또는 페이지가 만든 요소 */
groups: (MapToolAct | HTMLElement)[][];
/** 보조기기 이름 */
label: string;
/** [?] 안내 판(맨 앞 묶음) — 줄마다 한 칸 · 2열 */
hint?: { lines: string[]; open?: boolean; onToggle?: (open: boolean) => void };
}
export interface MapTools {
root: HTMLElement;
bar: HTMLElement;
hint: HTMLElement | null;
button(act: MapToolAct): HTMLButtonElement | null;
}
export function createMapTools(opts: MapToolsOptions): MapTools {
const root = document.createElement("div");
root.className = "ui-map-tools";
const groups = opts.groups.map((group) =>
group.map((item) => (typeof item === "string" ? mapToolButton(item) : item)),
);
let hint: HTMLElement | null = null;
if (opts.hint) {
const { lines, onToggle } = opts.hint;
const toggle = createIconButton({ icon: "help", title: "조작 도움말 펼치기 · 접기", size: 32 });
toggle.dataset.act = "hint-toggle";
const panel = document.createElement("div");
panel.className = "ui-map-tools__hint";
for (const line of lines)
panel.append(Object.assign(document.createElement("span"), { textContent: line }));
const setOpen = (open: boolean): void => {
panel.classList.toggle("is-open", open);
toggle.setAttribute("aria-expanded", String(open));
};
setOpen(opts.hint.open ?? false);
toggle.addEventListener("click", () => {
const open = !panel.classList.contains("is-open");
setOpen(open);
onToggle?.(open);
});
groups.unshift([toggle]);
hint = panel;
}
const bar = createToolbar({
glass: true,
size: 32,
label: opts.label,
items: groups.flatMap((group, i) => (i ? ["divider" as const, ...group] : group)),
});
root.append(bar);
if (hint) root.append(hint);
return {
root,
bar,
hint,
button: (act) => root.querySelector<HTMLButtonElement>(`[data-act="${act}"]`),
};
}
+23
View File
@@ -10,6 +10,7 @@ import { createFloatPanel } from "./ui_template_float_panel";
import { createFormGrid, createPageFrame } from "./ui_template_general_layout";
import { createInputField } from "./ui_template_input";
import { createListRow } from "./ui_template_list_row";
import { createMapTools, mapToolButton } from "./ui_template_map_tools";
import { createWorkflowPanelHandle } from "./ui_template_overlay";
import { createPanelHead } from "./ui_template_panel_head";
import { createPublicFrame } from "./ui_template_public_layout";
@@ -218,6 +219,27 @@ function toolbar(): HTMLElement {
return box;
}
function mapTools(): HTMLElement {
const tools = createMapTools({
label: "평면 도구",
hint: { lines: ["휠 = 확대", "가운데 끌기 = 이동", "Ctrl+휠 = 돌리기", "Esc = 그리기 끝"] },
groups: [
["undo", "redo"],
["view3d", "rotate-ccw", "rotate-cw"],
[
"contour-minor",
"stations",
mapToolButton("slopes", { pressed: true }),
"footprint",
"measure",
],
["station-snap"],
],
});
tools.button("contour-minor")!.disabled = true;
return tools.root;
}
/** 열쇠 → 견본(상태별 · null = 그 상태 없음) */
export const FRAME_DEMOS: Record<string, (state: KitState) => HTMLElement | null> = {
"work-frame": (s) => (s === "default" ? workFrame() : null),
@@ -226,6 +248,7 @@ export const FRAME_DEMOS: Record<string, (state: KitState) => HTMLElement | null
"float-panel": (s) => (s === "default" ? floatPanel() : null),
drawer: (s) => (s === "default" ? drawer() : null),
toolbar: (s) => (s === "default" ? toolbar() : null),
"map-tools": (s) => (s === "default" ? mapTools() : null),
steps,
"panel-head": (s) => (s === "default" ? panelHead() : null),
"list-row": listRow,
+2
View File
@@ -363,6 +363,8 @@ export const KIT_PARTS: KitPart[] = [
demo: FRAME_DEMOS["drawer"] },
{ key: "toolbar", name: "도구줄", group: "frame", kinds: "단추 묶음 · 나눔선 · 밀기 · 유리 · 24 · 28 · 32", file: "ui_template_toolbar.ts",
demo: FRAME_DEMOS["toolbar"] },
{ key: "map-tools", name: "지도 도구줄", group: "frame", kinds: "아이콘 단추 한 벌 · 묶음 나눔선 · 켬끔(aria-pressed) · [?] 안내 판 · 페이지가 쓸 단추만", file: "ui_template_map_tools.ts",
demo: FRAME_DEMOS["map-tools"] },
{ key: "steps", name: "단계 줄", group: "frame", kinds: "가로 막대 · compact(28) · 세로 목록(40 · ☰ 서랍)", file: "ui_template_workflow_layout.ts",
demo: FRAME_DEMOS.steps },
{ key: "panel-head", name: "패널 머리", group: "frame", kinds: "제목 · 부제 · 오른쪽 단추 · 밑줄", file: "ui_template_panel_head.ts",