feat(524-1): B07 평면도 설정 모달 정리 — 기본 항목 지형도 하나 · 양식 칸 숨김 · 종류 척도 2열 · 항목 그룹 구분선 · 대피소 사토장 자리 지움

- 기본 종류 = 지형도 하나(옛 기본 이관 더하기 지움) · 대피소 조각 · 자리 폼 · plan-marks 끝점 지움
- 모달 양식 칸 = 평면도면 숨김 · 종류 척도 영역 2열 · 항목 3열 유지 + 그룹 구분선(PIECE_GROUPS 한 곳)
- 낡은 시험 수선: 기본 종류가 하나로 줄어 route · layout · landuse · watershed 기본 전제를 add_kind 로 세움 · 대피소 자리 시험 지움

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 14:23:59 +09:00
co-authored by Claude Sonnet 5.5
parent 7e2bc3a8e7
commit a96a60fb86
13 changed files with 126 additions and 435 deletions
+1 -16
View File
@@ -273,7 +273,7 @@ export function saveDrawingScales(
});
}
/** 계획평면도 종류 · 고를 조각 · 대피소 자리 (PLAN 60장). */
/** 계획평면도 종류 · 고를 조각 (PLAN 60장). */
export interface PlanKind {
id: string;
name: string;
@@ -286,11 +286,6 @@ export interface PlanKind {
contour_interval_m?: number;
}
export type PlanKindLayout = "plan" | "watershed";
export interface PlanMark {
chainage_m: number;
refuge: boolean;
spoil: boolean;
}
export interface PlanKindsState {
pieces: { id: string; name: string }[];
/** 고를 수 있는 평면도 축척 분모(서버 `DRAWING_SCALE_PLAN_CHOICES`) · 기본 · 유역도 고정. */
@@ -301,9 +296,6 @@ export interface PlanKindsState {
contour_intervals: number[];
default_contour_interval: number;
kinds: PlanKind[];
marks: PlanMark[];
/** 종단 측점 간격(m) — 측점 NO + 거리 입력용(GET 만 · 종단이 없으면 null). */
station_interval_m?: number | null;
}
export function fetchPlanKinds(projectId: string): Promise<PlanKindsState> {
@@ -352,13 +344,6 @@ export function deletePlanKind(projectId: string, kindId: string): Promise<PlanK
});
}
export function savePlanMarks(projectId: string, marks: PlanMark[]): Promise<PlanKindsState> {
return requestJson(`/projects/${projectId}/plan-marks`, {
method: "PUT",
body: JSON.stringify({ marks }),
});
}
/** 도각 편집 화면이 싣는 도각 한 장 (실치수 1:1, 잠금 없는 도면층). */
export interface FrameTemplateResponse {
status: string;
@@ -1,8 +1,8 @@
"""B07 계획평면도 종류 — 정보 조각을 골라 한 장으로 얹는다(PLAN 60장).
계획평면도는 여러 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)가 **같은 양식**이다.
계획평면도는 여러 종류(지형도 · 사용자 추가)가 **같은 양식**이다.
정보 조각(등고선 · 중심선 · 노견선 · 성토 · 절토 외곽선 · 비탈 눈금 · IP · R · 측점 NO ·
시종점 · 대피소/사토장 · 배수시설 기호 · 라이다 음영)마다 프로젝트 저장소에 파일이 하나씩
시종점 · 배수시설 기호 · 라이다 음영)마다 프로젝트 저장소에 파일이 하나씩
있고, 종류는 그중 무엇을 얹을지 고른 목록이다. 이 모듈은 조각 기하 계산과 도면 조립만
한다(파일 입출력 없음).
@@ -83,7 +83,6 @@ PIECES: tuple[tuple[str, str, bool], ...] = (
("ip_table", "IP 표", False),
("station", "측점 NO", False),
("endpoints", "시종점", False),
("refuge", "대피소 · 사토장", False),
("drainage", "배수시설 기호", False),
# 용지도(93장) — 연속지적도 · 행정구역
("parcel", "필지 경계", False),
@@ -99,33 +98,9 @@ LEGACY_PIECES: dict[str, tuple[str, ...]] = {
_OUTLINE = LEGACY_PIECES["slope_outline"]
_TERRAIN = ("contour", "stream", "centerline", *_OUTLINE, "slope_ticks", "ip", "radius")
# 기본 종류 — 지울 수 있다. id 는 옛 도면 id(plan_terrain …)와 같아 확정본이 이어진다.
# 기본 종류 — 지울 수 있다. id 는 옛 도면 id(plan_terrain)와 같아 확정본이 이어진다.
DEFAULT_KINDS: tuple[dict[str, Any], ...] = (
{"id": "terrain", "name": "지형", "pieces": [*_TERRAIN, "station"]},
{
"id": "route",
"name": "노선배치도",
"pieces": [p for p in _TERRAIN if p != "radius"] + ["station", "endpoints", "ip_table"],
},
{
"id": "layout",
"name": "대피소",
"pieces": [p for p in _TERRAIN if p != "radius"] + ["station", "refuge"],
},
# 라이다 = 지형과 같은 정보 · 배경만 라이다(등고선 · 계류 대신).
{
"id": "lidar",
"name": "라이다",
"pieces": ["lidar", *[p for p in _TERRAIN if p not in ("contour", "stream")], "station"],
},
# 용지도 = 옛 따로 도면과 같은 정보(등고선 · 노선 · 지적 · 행정 경계).
{
"id": "landuse",
"name": "용지도",
"pieces": ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"],
},
# 배수유역도 = 양식이 다른 종류(1/6,000 한 장 · 정보표) — 조각을 고르지 않는다(109장).
{"id": "watershed", "name": "배수유역도", "pieces": [], "layout": "watershed"},
{"id": "terrain", "name": "지형도", "pieces": [*_TERRAIN, "station"]},
)
_COLORS = {
@@ -140,7 +115,6 @@ _COLORS = {
"radius": "#4d7dff",
"station": "#ffe066",
"endpoints": "#ff9d4d",
"refuge": "#7ddc7d",
"drainage": STRUCTURE_COLOR,
"parcel": "#8c6b4f",
"parcel_info": "#a8703f", # 밝음 · 어두움 바탕 둘 다 읽히는 중간 밤색
@@ -422,14 +396,6 @@ def _piece_entities(
],
(cx, cy),
)
elif piece == "refuge":
size = 2.0 # 네모 기호 반크기(종이 mm)
for index, item in enumerate(data.get("items") or []):
x, y, half = item["x"], item["y"], size / mm
corners = [(-1, -1), (1, -1), (1, 1), (-1, 1), (-1, -1)]
line([(x + sx * half, y + sy * half) for sx, sy in corners], f":{index}")
cx, cy = paper((x, y))
text(f"{index}", item["label"], _LABEL_FONT, _side_spots(cx, cy, size), (cx, cy))
elif piece == "drainage":
size = _STRUCTURE_SIZE_MM # 마름모 기호 반크기(종이 mm)
for index, item in enumerate(data.get("items") or []):
@@ -2,7 +2,7 @@
조각(`_piece_entities`)은 글을 바로 그리지 않고 후보 자리째 모은다. 여기서 우선순위 차례로
후보 중 첫 빈 자리를 고르고(못 찾으면 첫 후보) 멀리 놓인 글엔 지시선을 단다.
우선순위 = 시종점 > 측점 NO > IP > R > 배수 > 대피소. 서버 단독 계산(CLAUDE.md 5장 ③).
우선순위 = 시종점 > 측점 NO > IP > R > 배수. 서버 단독 계산(CLAUDE.md 5장 ③).
좌표 = 종이 mm. 글 사각 = 글 크기 × (한글 1.0 · 그 밖 0.6) 폭 · 글 크기 높이(세로 가운데 기준 —
CAD 가 textBaseline middle 로 그림) · 방향이 있으면 돌린 사각의 바깥 상자.
@@ -13,7 +13,7 @@ from typing import Any
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import _text_entity, polyline_entity
PRIORITY = ("endpoints", "station", "ip", "radius", "drainage", "refuge")
PRIORITY = ("endpoints", "station", "ip", "radius", "drainage")
_GAP_MM = 0.6 # 글 사이 틈(종이 mm)
_PUSH = (2.0, 3.0) # 먼 고리 — 후보 거리의 배수
@@ -1,7 +1,6 @@
"""B07 계획평면도 종류 · 대피소 자리 라우터 (PLAN 60장).
"""B07 계획평면도 종류 라우터 (PLAN 60장).
종류 = 정보 조각 고르기 목록. 추가 · 업데이트 · 지우기는 파일 하나씩(`plan_kinds/{id}.json`)이다.
대피소 · 사토장 자리는 구조물 정본 밖에서 사용자가 더 정한 자리(`plan_marks.json`)다.
"""
import asyncio
@@ -13,19 +12,14 @@ from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import PIECES
from B07_DesignDetail.B07_DesignDetail_Router import _confirmed_source
from B07_DesignDetail.B07_DesignDetail_Router_Frame import _project_root
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_json
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
add_kind,
delete_kind,
list_kinds,
read_marks,
reorder_kinds,
update_kind,
write_marks,
)
from config.config_system import (
BASIN_CONTOUR_INTERVAL_CHOICES,
@@ -54,16 +48,6 @@ class PlanKindsOrderRequest(BaseModel):
ids: list[str] = Field(default_factory=list)
class PlanMark(BaseModel):
chainage_m: float = Field(ge=0)
refuge: bool = False
spoil: bool = False
class PlanMarksRequest(BaseModel):
marks: list[PlanMark] = Field(default_factory=list)
def _state(root: Any) -> dict[str, Any]:
return {
"status": "success",
@@ -74,7 +58,6 @@ def _state(root: Any) -> dict[str, Any]:
"contour_intervals": list(BASIN_CONTOUR_INTERVAL_CHOICES),
"default_contour_interval": BASIN_CONTOUR_INTERVAL_M,
"kinds": list_kinds(root),
"marks": read_marks(root),
}
@@ -82,22 +65,11 @@ def _error(status: int, exc: Exception) -> JSONResponse:
return JSONResponse(status_code=status, content={"status": "error", "message": str(exc)})
async def _station_interval(project_id: UUID) -> float | None:
"""종단 측점 간격(m) — 측점 NO + 거리 입력을 누가거리로 바꾸는 기준. 종단이 없으면 None."""
try:
_route_id, _root, longitudinal_path, _bypassed = await _confirmed_source(project_id)
longitudinal = await asyncio.to_thread(_read_json, longitudinal_path)
except (FileNotFoundError, PermissionError, OSError, ValueError):
return None
return infer_station_interval(longitudinal.get("stations") or [])
@router.get("/{project_id}/plan-kinds")
async def get_plan_kinds(project_id: UUID) -> Any:
"""고를 수 있는 조각 · 지금 종류 · 대피소 자리 · 측점 간격."""
"""고를 수 있는 조각 · 지금 종류."""
root = await _project_root(project_id)
state = await asyncio.to_thread(_state, root)
return {**state, "station_interval_m": await _station_interval(project_id)}
return await asyncio.to_thread(_state, root)
@router.post("/{project_id}/plan-kinds")
@@ -153,7 +125,7 @@ async def put_plan_kind(project_id: UUID, kind_id: str, request: PlanKindRequest
@router.delete("/{project_id}/plan-kinds/{kind_id}")
async def delete_plan_kind(project_id: UUID, kind_id: str) -> Any:
"""종류 지우기 — 기본 넷도 지울 수 있다."""
"""종류 지우기 — 기본 종류도 지울 수 있다."""
try:
root = await _project_root(project_id)
await asyncio.to_thread(delete_kind, root, kind_id)
@@ -162,15 +134,3 @@ async def delete_plan_kind(project_id: UUID, kind_id: str) -> Any:
return _error(400, exc)
except FileNotFoundError as exc:
return _error(404, exc)
@router.put("/{project_id}/plan-marks")
async def put_plan_marks(project_id: UUID, request: PlanMarksRequest) -> Any:
"""사용자가 정한 대피소 · 사토장 자리를 통째로 갈아 쓴다."""
try:
root = await _project_root(project_id)
marks = [mark.model_dump() for mark in request.marks]
await asyncio.to_thread(write_marks, root, marks)
return await asyncio.to_thread(_state, root)
except ValueError as exc:
return _error(400, exc)
@@ -4,10 +4,8 @@
plan_kinds/{종류 id}.json 종류 하나 = {name, pieces, order, layout, scale} — 파일 하나씩
(유역도 양식은 + contour_interval_m 등고선 간격 5 · 10 m)
plan_pieces/{장}/{조각}.json 정보 조각 하나 = 그 장 범위의 기하(사업지 좌표 m)
plan_marks.json 사용자가 정한 대피소 · 사토장 자리
`plan_kinds/` 가 없으면 기본 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 배수유역도)를
쓴다. 양식(layout) = plan(1/1,200 장 나눔 · 조각 고름) · watershed(유역도 1/6,000 한 장 · 조각
`plan_kinds/` 가 없으면 기본 종류(지형도 하나)를 쓴다. 양식(layout) = plan(1/1,200 장 나눔 · 조각 고름) · watershed(유역도 1/6,000 한 장 · 조각
없음). 처음 고칠 때 기본 종류를 파일로 내려 두고 그 위에서 추가 · 업데이트 · 지운다 — 기본 종류도
고치고 지울 수 있다.
조각은 서버가 도면을 열 때마다 원자료(B05 노선 · 곡선표 · 구조물, B06 횡단 설계, B04 배경)
@@ -24,7 +22,6 @@ from B05_Profile.B05_Profile_Engine_Sections import (
cross_station,
find_cross_file,
)
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
infer_station_interval,
@@ -86,8 +83,6 @@ PLAN_KIND_ID = re.compile(r"^plan_([a-z][a-z0-9]{0,31})(?:_(\d+))?$")
_NAME_MAX = 40
WATERSHED_LAYOUT = "watershed"
_LAYOUTS = ("plan", WATERSHED_LAYOUT)
_REFUGE_TYPES = {"refuge": "대피소", "spoil_bank": "사토장"}
_REFUGE_OFFSET_M = 8.0 # 대피소 · 사토장 기호를 중심선에서 띄우는 거리(좌측 · 1/1,200 실거리)
def plan_title(name: str) -> str:
@@ -101,8 +96,9 @@ def _kinds_dir(project_root: Path) -> Path:
return _design_root(project_root) / "plan_kinds"
# 이관 표시 `_{id}_added` — 있으면 그 기본 종류를 이미 더해 준 폴더(지운 뒤 되살리지 않음).
_ADDED_ONCE = ("landuse", "watershed")
def _marks_path(project_root: Path) -> Path:
"""옛 대피소 · 사토장 자리 파일 — 읽지 않음 · [초기화]가 지우기만."""
return _design_root(project_root) / "plan_marks.json"
def _scale(layout: str, value: Any) -> int:
@@ -132,27 +128,6 @@ def _document(kind: dict[str, Any], order: int) -> dict[str, Any]:
}
def _add_defaults_once(folder: Path) -> None:
"""기본 종류가 늘기 전에 종류 파일을 내린 프로젝트 — 늘어난 기본 종류를 한 번 더한다."""
pending = [kind_id for kind_id in _ADDED_ONCE if not (folder / f"_{kind_id}_added").exists()]
if not pending:
return
documents = []
for path in folder.glob("*.json"):
try:
documents.append(_read_json(path))
except (OSError, ValueError):
continue
names = {str(document.get("name")) for document in documents}
order = max((int(document.get("order") or 0) for document in documents), default=-1) + 1
for kind_id in pending:
kind = next(kind for kind in DEFAULT_KINDS if kind["id"] == kind_id)
if not (folder / f"{kind_id}.json").exists() and kind["name"] not in names:
atomic_write_json(folder / f"{kind_id}.json", _document(kind, order))
order += 1
(folder / f"_{kind_id}_added").touch()
def list_kinds(project_root: Path) -> list[dict[str, Any]]:
"""종류 목록 [{id, name, pieces, layout, scale}] — 차례대로. 파일이 없으면 기본 종류."""
folder = _kinds_dir(project_root)
@@ -168,7 +143,6 @@ def list_kinds(project_root: Path) -> list[dict[str, Any]]:
}
for kind in DEFAULT_KINDS
]
_add_defaults_once(folder)
rows: list[tuple[int, str, dict[str, Any]]] = []
for path in folder.glob("*.json"):
if not KIND_ID.fullmatch(path.stem):
@@ -206,8 +180,6 @@ def _materialize(project_root: Path) -> list[dict[str, Any]]:
if not folder.is_dir():
for order, kind in enumerate(kinds):
atomic_write_json(folder / f"{kind['id']}.json", _document(kind, order))
for kind_id in _ADDED_ONCE:
(folder / f"_{kind_id}_added").touch()
return kinds
@@ -350,40 +322,6 @@ def delete_kind(project_root: Path, kind_id: str) -> None:
path.unlink()
# ── 대피소 · 사토장 자리(사용자 지정) ─────────────────
def _marks_path(project_root: Path) -> Path:
return _design_root(project_root) / "plan_marks.json"
def read_marks(project_root: Path) -> list[dict[str, Any]]:
path = _marks_path(project_root)
if not path.is_file():
return []
try:
marks = _read_json(path).get("marks")
except (OSError, ValueError):
return []
return [mark for mark in marks or [] if isinstance(mark, dict)]
def write_marks(project_root: Path, marks: list[dict[str, Any]]) -> list[dict[str, Any]]:
clean: list[dict[str, Any]] = []
for mark in marks:
chainage = mark.get("chainage_m")
if not isinstance(chainage, (int, float)) or chainage < 0:
raise ValueError("대피소 · 사토장 자리의 측점(m)이 올바르지 않습니다.")
refuge, spoil = bool(mark.get("refuge")), bool(mark.get("spoil"))
if refuge or spoil:
clean.append(
{"chainage_m": round(float(chainage), 2), "refuge": refuge, "spoil": spoil}
)
clean.sort(key=lambda mark: mark["chainage_m"])
atomic_write_json(_marks_path(project_root), {"marks": clean})
return clean
# ── 정보 조각 ─────────────────────────────────────────
@@ -457,50 +395,6 @@ def slope_records(
return records
def _refuge_items(
project_root: Path,
route: list[tuple[float, float, float]],
start_m: float,
end_m: float,
scale: int = DRAWING_SCALE_PLAN,
) -> dict[str, Any]:
"""대피소 · 사토장 = 구조물 정본의 그 종류 + 사용자가 정한 자리. 한 자리에 둘 다 가능.
띄움은 종이 거리가 같게 축척 따라(141-2)."""
offset = _REFUGE_OFFSET_M * scale / DRAWING_SCALE_PLAN
spots: dict[float, set[str]] = {}
_revision, structures = load_structures(str(project_root))
for structure in structures:
label = _REFUGE_TYPES.get(structure.type_id)
if not label:
continue
chainage = structure.chainage_m
if chainage is None and structure.start_m is not None and structure.end_m is not None:
chainage = (structure.start_m + structure.end_m) / 2.0
if chainage is not None:
spots.setdefault(round(float(chainage), 1), set()).add(label)
for mark in read_marks(project_root):
key = round(float(mark["chainage_m"]), 1)
if mark.get("refuge"):
spots.setdefault(key, set()).add("대피소")
if mark.get("spoil"):
spots.setdefault(key, set()).add("사토장")
items = []
for chainage, labels in sorted(spots.items()):
if not (start_m <= chainage <= end_m) or len(route) < 2:
continue
x, y, nx, ny = route_frame(route, chainage)
items.append(
{
"chainage_m": chainage,
"label": " · ".join(name for name in ("대피소", "사토장") if name in labels),
"x": x + nx * offset,
"y": y + ny * offset,
}
)
return {"items": items}
def build_pieces(
context: Any,
longitudinal: dict[str, Any],
@@ -582,8 +476,6 @@ def build_pieces(
label += f" L={length:.1f}m"
items.append({"label": label, "x": x, "y": y, "nx": nx, "ny": ny})
pieces["endpoints"] = {"items": items}
if "refuge" in wanted:
pieces["refuge"] = _refuge_items(project_root, route, start_m, end_m, scale)
if "drainage" in wanted:
min_x, min_y, max_x, max_y = box
pieces["drainage"] = {
@@ -1,34 +1,28 @@
/* =============================================================================
* B07_DesignDetail_UI_PlanKinds.ts
* 좌측 도면 목록의 「평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 ·
* 좌측 도면 목록의 「평면도」 한 묶음 — 종류(지형도 ·
* 사용자 추가)별 도면 단추 + [평면도 관리] 단추 (PLAN 60 · 67 · 130장).
* 종류 추가 · 업데이트 폼 · 대피소 · 사토장 자리 폼은 [평면도 관리] 모달(`_UI_PlanManage.ts`)이 씀.
* 종류 추가 · 업데이트 폼은 [평면도 관리] 모달(`_UI_PlanManage.ts`)이 씀.
*
* 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다.
* 드롭다운 · 체크박스는 공용(`createSelectField` · `ui_template_checkbox`) · 측점 입력은 B05 구조물 폼과 같은 측점 NO + 거리 두 칸.
* 드롭다운 · 체크박스는 공용(`createSelectField` · `ui_template_checkbox`).
* ========================================================================== */
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createPanelCard } from "@ui/ui_template_collapsible";
import { createSelectField, showToast } from "@ui/ui_template_elements";
import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields";
import {
addPlanKind,
deletePlanKind,
savePlanMarks,
updatePlanKind,
type DesignDrawingItem,
type PlanKindLayout,
type PlanKindsState,
type PlanMark,
} from "./B07_DesignDetail_Api_Fetch";
/** 종단이 없어 측점 간격을 못 받았을 때 — 서버 `infer_station_interval` 기본값과 같음. */
const DEFAULT_STATION_INTERVAL_M = 20;
export interface PlanKindsControls {
projectId: string;
/** 종류 · 대피소 자리를 고친 뒤 — 목록과 도면을 다시 받는다. */
/** 종류를 고친 뒤 — 목록과 도면을 다시 받는다. */
onChanged: () => void;
}
@@ -56,6 +50,24 @@ export async function run(
}
}
/** 항목 체크박스 그룹 — 그룹 사이는 구분선만 · 그룹 추가 · 바꿈은 이 목록 한 곳.
* 어느 그룹에도 없는 항목은 맨 끝 그룹에 붙는다. */
const PIECE_GROUPS: string[][] = [
["lidar", "contour", "stream", "parcel", "parcel_info", "emd", "sgg"], // 수치지형
["basin", "basin_no", "drainage"], // 배수유역
[
"centerline",
"road_edge",
"fill_outline",
"cut_outline",
"slope_ticks",
"ip",
"radius",
"ip_table",
], // 노선
["station", "endpoints"], // 측점 관련
];
/** 양식 — 평면도(조각 고름 · 축척 고름 · 장 나눔) · 유역도(1/6,000 한 장 · 그림 고정). */
const LAYOUTS: { value: PlanKindLayout; text: string }[] = [
{ value: "plan", text: "평면도" },
@@ -114,7 +126,16 @@ export function kindForm(
name.maxLength = 40;
const boxes = state.pieces.map((piece) => createCheckbox(piece.name, { value: piece.id }));
const pieceList = el("div", "b07-plan-tools__pieces");
pieceList.append(...boxes.map((box) => box.root));
const grouped = PIECE_GROUPS.map((ids) => boxes.filter(({ input }) => ids.includes(input.value)));
grouped[grouped.length - 1].push(
...boxes.filter((box) => !PIECE_GROUPS.some((ids) => ids.includes(box.input.value))),
);
grouped
.filter((group) => group.length)
.forEach((group, index) => {
if (index) pieceList.append(el("hr", "b07-plan-tools__divider"));
pieceList.append(...group.map((box) => box.root));
});
const action = el("button", "b07-plan-tools__button");
action.type = "button";
const remove = el("button", "b07-plan-tools__button", "지우기");
@@ -147,6 +168,7 @@ export function kindForm(
// 양식은 새로 만들 때만 고른다 — 있는 종류는 그 양식을 보이기만.
layout.select.value = kind?.layout ?? "plan";
layout.select.disabled = Boolean(kind);
layout.root.hidden = kind?.layout === "plan"; // 평면도는 늘 같은 값 — 새 종류 · 유역도만 보임
action.textContent = kind ? "업데이트" : "추가";
remove.hidden = !kind;
showPieces();
@@ -177,7 +199,9 @@ export function kindForm(
});
const actions = el("div", "b07-plan-tools__actions");
actions.append(action, remove);
form.append(pick.root, layout.root, scale.root, contour.root, name, pieceList, actions);
const fields = el("div", "b07-plan-tools__fields");
fields.append(pick.root, layout.root, scale.root, contour.root);
form.append(fields, name, pieceList, actions);
return {
root: form,
pick: (kindId) => {
@@ -187,61 +211,6 @@ export function kindForm(
};
}
export function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
const form = el("div", "b07-plan-tools__block");
form.append(el("strong", "", "대피소 · 사토장 자리"));
form.append(
el(
"p",
"b07-plan-tools__hint",
"구조물로 놓은 대피소 · 사토장은 저절로 실립니다. 더 놓을 측점(NO + 거리)만 적으세요.",
),
);
const interval = state.station_interval_m || DEFAULT_STATION_INTERVAL_M;
const rows = el("div", "b07-plan-tools__marks");
const readers = new Map<HTMLElement, () => PlanMark | null>();
const addRow = (mark: PlanMark): void => {
const row = el("div", "b07-plan-tools__mark");
const station = stationFields("측점", () => interval);
station.write(mark.chainage_m, interval);
const refuge = createCheckbox("대피소", { checked: mark.refuge });
const spoil = createCheckbox("사토장", { checked: mark.spoil });
const remove = el("button", "b07-plan-tools__remove", "×");
remove.type = "button";
remove.title = "이 자리 빼기";
remove.addEventListener("click", () => {
readers.delete(row);
row.remove();
});
row.append(station.wrap, refuge.root, spoil.root, remove);
readers.set(row, () => {
const chainage = station.read(interval, true);
return chainage === null
? null
: { chainage_m: chainage, refuge: refuge.input.checked, spoil: spoil.input.checked };
});
rows.append(row);
};
state.marks.forEach(addRow);
const more = el("button", "b07-plan-tools__button", "자리 추가");
more.type = "button";
more.addEventListener("click", () => addRow({ chainage_m: 0, refuge: true, spoil: false }));
const save = el("button", "b07-plan-tools__button", "업데이트");
save.type = "button";
save.addEventListener("click", () => {
const marks = [...readers.values()].map((read) => read());
if (marks.some((mark) => mark === null)) {
showToast("측점 NO + 거리를 바르게 적어 주세요.", "error");
return;
}
void run(() => savePlanMarks(controls.projectId, marks as PlanMark[]), controls);
});
const actions = el("div", "b07-plan-tools__actions");
actions.append(more, save);
form.append(rows, actions);
return form;
}
/** 장을 종류별로 묶음 — 여러 장이면 「이름 N장」 · 한 장이면 이름(좌측 목록 · 관리 모달 공용). */
export function groupPlanSheets(
items: DesignDrawingItem[],
@@ -1,9 +1,9 @@
/* =============================================================================
* B07_DesignDetail_UI_PlanManage.ts
* [평면도 관리] 모달(PLAN 130-1) — 좌측 패널이 좁아 종류 · 대피소 자리 · 장 보기를 한 창에 모음.
* [평면도 관리] 모달(PLAN 130-1) — 좌측 패널이 좁아 종류 · 장 보기를 한 창에 모음.
*
* 왼쪽 = [종류 ▼] 상세 폼(추가 · 업데이트 · 지우기) · 평면도 장(종류마다 한 줄 [≡] 이름 [1][2] ·
* 줄 끌어 놓기 = 종류 순서 · 놓는 즉시 저장 140-2) · 대피소 · 사토장 자리
* 줄 끌어 놓기 = 종류 순서 · 놓는 즉시 저장 140-2)
* 오른쪽 = 고른 장 미리보기(보기 전용 CAD · 캔버스만 `bare`) · 확정 배지 · [CAD 에서 열기]
* 고치기는 지금 서버 길 그대로(종류 파일) — 고칠 때마다 모달 안을 다시 받고,
* 닫을 때 `onClosed(changed)` 로 좌측 목록 · 도면을 다시 읽음.
@@ -25,7 +25,6 @@ import { openDrawing, syncDrawings } from "./B07_DesignDetail_Prefetch";
import {
groupPlanSheets,
kindForm,
marksForm,
run,
type KindForm,
type PlanKindsControls,
@@ -33,7 +32,7 @@ import {
export interface PlanManageControls {
projectId: string;
/** 모달을 닫음 — changed = 종류 · 자리 · 확정을 고쳤나 · openId = [CAD 에서 열기]로 고른 장. */
/** 모달을 닫음 — changed = 종류 · 확정을 고쳤나 · openId = [CAD 에서 열기]로 고른 장. */
onClosed: (changed: boolean, openId?: string) => void;
/** 머리 단추 — ⚙ 로 열 때 [원본에서 다시 그리기](280-5). */
actions?: HTMLElement[];
@@ -80,7 +79,7 @@ export function openPlanManager(controls: PlanManageControls): { close: () => vo
const view = el("div", "b07-plan-manage__view");
view.append(head, preview.element);
// 종류 · 자리 폼은 좌측에 있던 것 그대로 — 고친 뒤 모달 안만 다시 세움(화면은 닫을 때).
// 종류 폼은 좌측에 있던 것 그대로 — 고친 뒤 모달 안만 다시 세움(화면은 닫을 때).
const edits: PlanKindsControls = {
projectId,
onChanged: () => {
@@ -233,7 +232,7 @@ export function openPlanManager(controls: PlanManageControls): { close: () => vo
if (first) void show(first.drawing, first.label);
else markActive();
});
side.replaceChildren(form.root, sheetBlock(state), marksForm(state, edits));
side.replaceChildren(form.root, sheetBlock(state));
if (keep) void show(keep.drawing, keep.label);
else {
picked = undefined;
+19 -39
View File
@@ -282,7 +282,6 @@
}
.b07-plan-kind__head,
.b07-plan-tools__mark,
.b07-plan-tools__actions {
display: flex;
align-items: center;
@@ -406,6 +405,24 @@
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
}
.b07-plan-tools__fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-4);
}
.b07-plan-tools__fields > [hidden] {
display: none;
}
.b07-plan-tools__divider {
grid-column: 1 / -1;
width: 100%;
margin: var(--spacing-4) 0;
border: 0;
border-top: 1px solid var(--color-border);
}
.b07-plan-tools__pieces[hidden] {
display: none;
}
@@ -414,40 +431,7 @@
min-width: 0;
}
.b07-plan-tools__hint {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
/* 측점 NO + 거리 두 칸(B05 stationFields) — 한 줄: [측점][NO][+m] */
.b07-plan-tools__mark .b05-route__field {
display: flex;
flex: 1;
align-items: center;
gap: var(--spacing-4);
min-width: 0;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.b07-plan-tools__mark .b05-structure__station-pair {
display: flex;
gap: var(--spacing-4);
min-width: 0;
}
.b07-plan-tools__mark .b05-structure__station-pair input {
width: 52px;
min-width: 0;
}
.b07-plan-tools__mark input.is-invalid {
border-color: var(--color-danger);
}
.b07-plan-tools__button,
.b07-plan-tools__remove {
.b07-plan-tools__button {
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
@@ -456,10 +440,6 @@
cursor: pointer;
}
.b07-plan-tools__remove {
padding: 0 var(--spacing-8);
}
/* 목록에서 연 도면 탭 줄 + CAD(82-4) */
.b07-cad-stack {
display: flex;