From 4c96e8f8ddab83024765b2c29f4bf7f1d83515ed Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 22:59:53 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(B07):=20=EA=B3=84=ED=9A=8D=ED=8F=89?= =?UTF-8?q?=EB=A9=B4=EB=8F=84=20=EC=A1=B0=EA=B0=81=20=EC=9E=98=EA=B2=8C=20?= =?UTF-8?q?=C2=B7=20=EC=A2=85=EB=A5=98=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20API=20(67-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 성토사면 외곽선 → 노견선 · 성토 외곽선 · 절토 외곽선 셋 · 배수시설 기호 조각 신설 · 라이다 음영 - 기본 넷 처음 체크 = 지금 그림(배수시설 기호는 빼 둠) · 옛 종류 파일 slope_outline 은 셋으로 펼침 - PUT /plan-kinds/{id} — 이름 · 조각 고침(기본 넷 포함) · 그 종류 확정본 풀림 - GET /plan-kinds 에 측점 간격 실음 — 측점 NO + 거리 입력용 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../B07_DesignDetail_Engine_Cad_PlanKinds.py | 152 +++++++++++++----- .../B07_DesignDetail_Router_PlanKinds.py | 36 ++++- .../B07_DesignDetail_Router_Support_Plan.py | 82 ++++++++-- resources/tester/test_b07_plan_kinds.py | 77 ++++++++- 4 files changed, 296 insertions(+), 51 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index 34e5cc64b..cd51607fb 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -1,9 +1,10 @@ """B07 계획평면도 종류 — 정보 조각을 골라 한 장으로 얹는다(PLAN 60장). 계획평면도는 여러 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)가 **같은 양식**이다. -정보 조각(등고선 · 중심선 · 비탈 외곽선 · 비탈 눈금 · IP · R · 측점 NO · 시종점 · -대피소/사토장 · 라이다 배경)마다 프로젝트 저장소에 파일이 하나씩 있고, 종류는 그중 -무엇을 얹을지 고른 목록이다. 이 모듈은 조각 기하 계산과 도면 조립만 한다(파일 입출력 없음). +정보 조각(등고선 · 중심선 · 노견선 · 성토 · 절토 외곽선 · 비탈 눈금 · IP · R · 측점 NO · +시종점 · 대피소/사토장 · 배수시설 기호 · 라이다 음영)마다 프로젝트 저장소에 파일이 하나씩 +있고, 종류는 그중 무엇을 얹을지 고른 목록이다. 이 모듈은 조각 기하 계산과 도면 조립만 +한다(파일 입출력 없음). 좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 도곽 최소점) x MM (1/1,200). """ @@ -25,10 +26,13 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( _COMPASS_SIZE, _FONT_SIZE, _ROUTE_WIDTH, + _STRUCTURE_FONT_SIZE, + _STRUCTURE_SIZE_MM, _TITLE_FONT_SIZE, CONTOUR_COLOR, MM, STREAM_COLOR, + STRUCTURE_COLOR, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( compass_entities, @@ -40,21 +44,29 @@ from config.config_system import DRAWING_SCALE_PLAN # 조각 (id, 이름, 도면층 잠금) — 그리는 차례(배경이 먼저). PIECES: tuple[tuple[str, str, bool], ...] = ( - ("lidar", "라이다 배경", True), + ("lidar", "라이다 음영", True), ("contour", "등고선", True), ("stream", "계류", True), ("slope_ticks", "비탈 눈금", False), - ("slope_outline", "성토사면 외곽선", False), + ("road_edge", "노견선", False), + ("fill_outline", "성토 외곽선", False), + ("cut_outline", "절토 외곽선", False), ("centerline", "중심선", False), ("ip", "IP", False), ("radius", "R", False), ("station", "측점 NO", False), ("endpoints", "시종점", False), ("refuge", "대피소 · 사토장", False), + ("drainage", "배수시설 기호", False), ) PIECE_IDS: tuple[str, ...] = tuple(piece for piece, _name, _locked in PIECES) +# 옛 조각 id → 나눈 조각들 — 옛 종류 파일을 읽을 때 펼친다. +LEGACY_PIECES: dict[str, tuple[str, ...]] = { + "slope_outline": ("road_edge", "fill_outline", "cut_outline"), +} +_OUTLINE = LEGACY_PIECES["slope_outline"] -_TERRAIN = ("contour", "stream", "centerline", "slope_outline", "slope_ticks", "ip", "radius") +_TERRAIN = ("contour", "stream", "centerline", *_OUTLINE, "slope_ticks", "ip", "radius") # 기본 종류 넷 — 지울 수 있다. id 는 옛 도면 id(plan_terrain …)와 같아 확정본이 이어진다. DEFAULT_KINDS: tuple[dict[str, Any], ...] = ( {"id": "terrain", "name": "지형", "pieces": [*_TERRAIN, "station"]}, @@ -80,13 +92,16 @@ _COLORS = { "contour": CONTOUR_COLOR, "stream": STREAM_COLOR, "centerline": "#ff4d4d", - "slope_outline": "#4dd8e6", + "road_edge": "#4dd8e6", + "fill_outline": "#4dd8e6", + "cut_outline": "#4dd8e6", "slope_ticks": "#ff4d4d", "ip": "#4d7dff", "radius": "#4d7dff", "station": "#ffe066", "endpoints": "#ff9d4d", "refuge": "#7ddc7d", + "drainage": STRUCTURE_COLOR, } TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 _MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수) @@ -129,6 +144,27 @@ def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float return before[key] + (after[key] - before[key]) * ratio +def _ground(samples: list[dict[str, Any]]) -> list[tuple[float, float]]: + return sorted( + (float(item["offset_m"]), float(item["elevation_m"])) + for item in samples + if item.get("valid", True) and isinstance(item.get("elevation_m"), (int, float)) + ) + + +def _ground_at(ground: list[tuple[float, float]], offset: float) -> float: + offsets = [x for x, _z in ground] + index = min(max(bisect_left(offsets, offset), 1), len(ground) - 1) + (x0, z0), (x1, z1) = ground[index - 1], ground[index] + return z0 + (z1 - z0) * ((offset - x0) / (x1 - x0) if x1 > x0 else 0.0) + + +def side_is_cut(samples: list[dict[str, Any]], edge: float, elevation: float) -> bool: + """노견 끝에서 지반이 노면보다 높으면 그 쪽 비탈은 절토다(낮으면 성토).""" + ground = _ground(samples) + return len(ground) >= 2 and _ground_at(ground, edge) > elevation + + def slope_end( design_line: list[dict[str, Any]], samples: list[dict[str, Any]], edge: float, side: str ) -> float: @@ -137,11 +173,7 @@ def slope_end( 설계선(B06 횡단 설계 결과)은 비탈이 지반과 만난 뒤로 지반을 따라간다 — 그 첫 자리가 끝이다. 좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다. """ - ground = sorted( - (float(item["offset_m"]), float(item["elevation_m"])) - for item in samples - if item.get("valid", True) and isinstance(item.get("elevation_m"), (int, float)) - ) + ground = _ground(samples) points = sorted((float(p["offset_m"]), float(p["elevation_m"])) for p in design_line) outward = ( [p for p in points if p[0] > edge] @@ -150,36 +182,61 @@ def slope_end( ) if not ground: return outward[-1][0] if outward else edge - offsets = [offset for offset, _z in ground] - - def ground_at(offset: float) -> float: - index = min(max(bisect_left(offsets, offset), 1), len(ground) - 1) - (x0, z0), (x1, z1) = ground[index - 1], ground[index] - return z0 + (z1 - z0) * ((offset - x0) / (x1 - x0) if x1 > x0 else 0.0) - end, differs = edge, False for offset, elevation in outward: - if abs(elevation - ground_at(offset)) <= 1e-3: + if abs(elevation - _ground_at(ground, offset)) <= 1e-3: return offset if differs else end differs, end = True, offset return end +def _split_runs( + points: list[list[float]], flags: list[bool] +) -> tuple[list[list[list[float]]], list[list[list[float]]]]: + """한 선을 참 · 거짓 구간으로 끊는다 — 바뀌는 자리 점은 앞뒤 구간이 나눠 가져 이어진다.""" + runs: dict[bool, list[list[list[float]]]] = {True: [], False: []} + current: list[list[float]] = [] + previous: bool | None = None + for point, flag in zip(points, flags): + if flag != previous: + if current: + current.append(point) + current = [point] + runs[flag].append(current) + previous = flag + else: + current.append(point) + return ( + [run for run in runs[True] if len(run) >= 2], + [run for run in runs[False] if len(run) >= 2], + ) + + def slope_pieces( route: list[tuple[float, float, float]], records: list[dict[str, Any]], start_m: float, end_m: float, -) -> tuple[dict[str, Any], dict[str, Any]]: - """측점별 노견 끝 · 비탈 끝 오프셋에서 외곽선 넷과 비탈 눈금을 만든다. +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]: + """측점별 노견 끝 · 비탈 끝 오프셋에서 노견선 · 성토 외곽선 · 절토 외곽선 · 비탈 눈금을 만든다. `records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m, - (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 중심 · 좌축을 그대로 - 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 따라 보간한다. + left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 + 중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 + 따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다. """ - records = sorted(records, key=lambda record: record["chainage_m"]) + records = sorted( + ( + { + **record, + **{f"{s}_cut": float(bool(record.get(f"{s}_cut"))) for s in ("left", "right")}, + } + for record in records + ), + key=lambda record: record["chainage_m"], + ) if len(route) < 2 or not records: - return {"lines": [], "stations": []}, {"ticks": []} + return {"lines": []}, {"lines": []}, {"lines": []}, {"ticks": []} keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m") by_chainage = {round(record["chainage_m"], 3): record for record in records} samples = { @@ -192,6 +249,7 @@ def slope_pieces( samples.add(round(step, 3)) step += TICK_STEP_M lines: dict[str, list[list[float]]] = {key: [] for key in keys} + cut: dict[str, list[bool]] = {"left": [], "right": []} ticks: list[list[list[float]]] = [] for index, chainage in enumerate(sorted(samples)): record = by_chainage.get(chainage) @@ -206,6 +264,8 @@ def slope_pieces( } for key in keys: lines[key].append(points[key]) + for side in cut: + cut[side].append(_interp(records, chainage, f"{side}_cut") > 0.5) # 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례). for side in ("left", "right"): edge, end = offsets[f"{side}_edge_m"], offsets[f"{side}_end_m"] @@ -213,15 +273,18 @@ def slope_pieces( continue reach = end if index % 2 == 0 else edge + (end - edge) / 2.0 ticks.append([points[f"{side}_edge_m"], [x + nx * reach, y + ny * reach]]) - outline = { - "lines": [lines[key] for key in keys], - "stations": [ - {key: record[key] for key in ("chainage_m", *keys)} - for record in records - if start_m <= record["chainage_m"] <= end_m - ], - } - return outline, {"ticks": ticks} + cut_lines: list[list[list[float]]] = [] + fill_lines: list[list[list[float]]] = [] + for side in cut: + cut_runs, fill_runs = _split_runs(lines[f"{side}_end_m"], cut[side]) + cut_lines += cut_runs + fill_lines += fill_runs + return ( + {"lines": [lines["left_edge_m"], lines["right_edge_m"]]}, + {"lines": fill_lines}, + {"lines": cut_lines}, + {"ticks": ticks}, + ) def curve_pieces( @@ -326,7 +389,7 @@ def _piece_entities( }, } ) - elif piece in ("contour", "stream", "slope_outline"): + elif piece in ("contour", "stream", *_OUTLINE): for index, points in enumerate(data.get("lines") or []): line(points, f":{index}") elif piece == "centerline": @@ -395,6 +458,23 @@ def _piece_entities( line([(x + sx * half, y + sy * half) for sx, sy in corners], f":{index}") cx, cy = paper((x, y)) text(f"{index}", item["label"], (cx + size + 1.0, cy), _LABEL_FONT, align="left") + elif piece == "drainage": + size = _STRUCTURE_SIZE_MM # 마름모 기호 반크기(종이 mm) + for index, item in enumerate(data.get("items") or []): + cx, cy = paper((item["x"], item["y"])) + corners = [(0, 1), (1, 0), (0, -1), (-1, 0), (0, 1)] + diamond = polyline_entity( + drawing_id, + [(cx + sx * size, cy + sy * size) for sx, sy in corners], + layer, + color, + suffix=f":{index}", + ) + if diamond: + out.append(diamond) + text( + f"{index}", item["label"], (cx + size + 1.0, cy), _STRUCTURE_FONT_SIZE, align="left" + ) return out diff --git a/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py index a6ec7848d..051b9622e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py @@ -1,6 +1,6 @@ """B07 계획평면도 종류 · 대피소 자리 라우터 (PLAN 60장). -종류 = 정보 조각 고르기 목록. 더하기 · 지우기는 파일 하나씩(`plan_kinds/{id}.json`)이다. +종류 = 정보 조각 고르기 목록. 추가 · 업데이트 · 지우기는 파일 하나씩(`plan_kinds/{id}.json`)이다. 대피소 · 사토장 자리는 구조물 정본 밖에서 사용자가 더 정한 자리(`plan_marks.json`)다. """ @@ -13,13 +13,17 @@ 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, + update_kind, write_marks, ) @@ -55,16 +59,27 @@ 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) - return await asyncio.to_thread(_state, root) + state = await asyncio.to_thread(_state, root) + return {**state, "station_interval_m": await _station_interval(project_id)} @router.post("/{project_id}/plan-kinds") async def post_plan_kind(project_id: UUID, request: PlanKindRequest) -> Any: - """종류 더하기 — 조각을 골라 이름을 붙인다.""" + """종류 추가 — 조각을 골라 이름을 붙인다.""" try: root = await _project_root(project_id) await asyncio.to_thread(add_kind, root, request.name, request.pieces) @@ -73,6 +88,19 @@ async def post_plan_kind(project_id: UUID, request: PlanKindRequest) -> Any: return _error(400, exc) +@router.put("/{project_id}/plan-kinds/{kind_id}") +async def put_plan_kind(project_id: UUID, kind_id: str, request: PlanKindRequest) -> Any: + """종류 업데이트 — 이름 · 조각을 고친다(기본 넷도).""" + try: + root = await _project_root(project_id) + await asyncio.to_thread(update_kind, root, kind_id, request.name, request.pieces) + return await asyncio.to_thread(_state, root) + except ValueError as exc: + return _error(400, exc) + except FileNotFoundError as exc: + return _error(404, exc) + + @router.delete("/{project_id}/plan-kinds/{kind_id}") async def delete_plan_kind(project_id: UUID, kind_id: str) -> Any: """종류 지우기 — 기본 넷도 지울 수 있다.""" diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py index 70770f60e..1274d78e1 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -6,7 +6,7 @@ plan_marks.json 사용자가 정한 대피소 · 사토장 자리 `plan_kinds/` 가 없으면 기본 넷(지형 · 노선배치도 · 대피소 · 라이다)을 쓴다. 처음 고칠 때 -기본 넷을 파일로 내려 두고 그 위에서 더하고 지운다 — 기본 넷도 지울 수 있다. +기본 넷을 파일로 내려 두고 그 위에서 추가 · 업데이트 · 지운다 — 기본 넷도 고치고 지울 수 있다. 조각은 서버가 도면을 열 때마다 원자료(B05 노선 · 곡선표 · 구조물, B06 횡단 설계, B04 배경) 에서 다시 계산해 파일로 쓰고, 도면은 그 파일을 읽어 고른 조각만 얹는다. """ @@ -24,23 +24,35 @@ from B05_Profile.B05_Profile_Engine_Sections import ( 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, station_no_label -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import plan_area_mm, plan_chunks +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( + _structure_label, + plan_area_mm, + plan_chunks, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( DEFAULT_KINDS, + LEGACY_PIECES, PIECE_IDS, curve_pieces, route_frame, + side_is_cut, slope_end, slope_pieces, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( _chunk_route, _hillshade_for_box, + _plan_structures, _sheet_box, map_background, plan_stations, ) -from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _design_root, _read_json +from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( + _design_root, + _read_json, + _read_manifest, + _write_manifest, +) from common_util.common_util_json import atomic_write_json from common_util.common_util_route_profile import design_elevation_from_longitudinal from config.config_system import DRAWING_SCALE_PLAN @@ -76,7 +88,10 @@ def list_kinds(project_root: Path) -> list[dict[str, Any]]: document = _read_json(path) except (OSError, ValueError): continue - pieces = [piece for piece in document.get("pieces") or [] if piece in PIECE_IDS] + stored = set() + for piece in document.get("pieces") or []: + stored.update(LEGACY_PIECES.get(piece, (piece,))) + pieces = [piece for piece in PIECE_IDS if piece in stored] rows.append( ( int(document.get("order") or 0), @@ -100,13 +115,18 @@ def _materialize(project_root: Path) -> list[dict[str, Any]]: return kinds -def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any]: +def _checked(name: str, pieces: list[str]) -> tuple[str, list[str]]: text = str(name or "").strip() if not text or len(text) > _NAME_MAX: raise ValueError(f"종류 이름은 1~{_NAME_MAX}자로 적어 주세요.") chosen = [piece for piece in PIECE_IDS if piece in set(pieces or [])] if not chosen: raise ValueError("얹을 정보를 하나 이상 고르세요.") + return text, chosen + + +def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any]: + text, chosen = _checked(name, pieces) kinds = _materialize(project_root) if any(kind["name"] == text for kind in kinds): raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}") @@ -118,6 +138,31 @@ def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any] return {"id": kind_id, "name": text, "pieces": chosen} +def update_kind(project_root: Path, kind_id: str, name: str, pieces: list[str]) -> dict[str, Any]: + """종류 이름 · 조각을 고친다(차례는 그대로). 그 종류의 확정본은 풀어 새 조각으로 다시 그린다.""" + if not KIND_ID.fullmatch(kind_id): + raise ValueError("올바르지 않은 종류 id 입니다.") + text, chosen = _checked(name, pieces) + kinds = _materialize(project_root) + if any(kind["name"] == text and kind["id"] != kind_id for kind in kinds): + raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}") + path = _kinds_dir(project_root) / f"{kind_id}.json" + if not path.is_file(): + raise FileNotFoundError("고칠 종류를 찾을 수 없습니다.") + order = int(_read_json(path).get("order") or 0) + atomic_write_json(path, {"name": text, "pieces": chosen, "order": order}) + manifest = _read_manifest(project_root) + released = False + for drawing_id, entry in manifest["drawings"].items(): + match = PLAN_KIND_ID.fullmatch(drawing_id) + if match and match.group(1) == kind_id and entry.get("confirmed"): + entry["confirmed"] = False + released = True + if released: + _write_manifest(project_root, manifest) + return {"id": kind_id, "name": text, "pieces": chosen} + + def delete_kind(project_root: Path, kind_id: str) -> None: if not KIND_ID.fullmatch(kind_id): raise ValueError("올바르지 않은 종류 id 입니다.") @@ -223,6 +268,10 @@ def slope_records( "right_edge_m": right_edge, "right_end_m": slope_end(line, samples, right_edge, "right"), } + for side in ("left", "right"): + elevation = edges[side].get("elevation_m") + if isinstance(elevation, (int, float)): + record[f"{side}_cut"] = side_is_cut(samples, record[f"{side}_edge_m"], elevation) frame = source.get("frame") or {} origin, axis = frame.get("origin") or {}, frame.get("left_xy") if isinstance(axis, list) and len(axis) == 2 and "x" in origin and "y" in origin: @@ -298,11 +347,14 @@ def build_pieces( pieces["stream"] = {"lines": background["streams"]} if "centerline" in wanted: pieces["centerline"] = {"lines": [route_xy]} - if {"slope_outline", "slope_ticks"} & set(wanted): + if {*LEGACY_PIECES["slope_outline"], "slope_ticks"} & set(wanted): records = slope_records(longitudinal_path, longitudinal, designs, start_m, end_m) - pieces["slope_outline"], pieces["slope_ticks"] = slope_pieces( - route, records, start_m, end_m - ) + ( + pieces["road_edge"], + pieces["fill_outline"], + pieces["cut_outline"], + pieces["slope_ticks"], + ) = slope_pieces(route, records, start_m, end_m) if {"ip", "radius"} & set(wanted): pieces["ip"], pieces["radius"] = curve_pieces(_load_design_curves(project_root), route, box) if "station" in wanted: @@ -341,6 +393,18 @@ def build_pieces( pieces["endpoints"] = {"items": items} if "refuge" in wanted: pieces["refuge"] = _refuge_items(project_root, route, start_m, end_m) + if "drainage" in wanted: + min_x, min_y, max_x, max_y = box + pieces["drainage"] = { + "items": [ + {"x": float(item["x"]), "y": float(item["y"]), "label": _structure_label(item)} + for item in _plan_structures(context) + if isinstance(item.get("x"), (int, float)) + and isinstance(item.get("y"), (int, float)) + and min_x <= item["x"] <= max_x + and min_y <= item["y"] <= max_y + ] + } if "lidar" in wanted: try: image, image_box = _hillshade_for_box(context, box) diff --git a/resources/tester/test_b07_plan_kinds.py b/resources/tester/test_b07_plan_kinds.py index da75092e7..a3cc127a6 100644 --- a/resources/tester/test_b07_plan_kinds.py +++ b/resources/tester/test_b07_plan_kinds.py @@ -4,6 +4,8 @@ ② 종류 더하기 · 지우기(기본 넷 포함) · 다시 읽어 같음 ③ 대피소 · 사토장 자리 — 한 자리에 둘 다 ④ 고른 조각만 도면 층으로 얹힘 +⑤ 조각 잘게(67장) — 노견선 · 성토 외곽선 · 절토 외곽선 따로 · 기본 넷 처음 체크 = 옛 그림 · + 옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호 """ import json @@ -19,13 +21,16 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( layer_id, slope_pieces, ) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_manifest, _write_manifest from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( + _kinds_dir, _refuge_items, add_kind, delete_kind, list_kinds, read_marks, slope_records, + update_kind, write_marks, ) @@ -89,8 +94,11 @@ def test_outline_width_equals_cross_design_at_two_stations(tmp_path: Path) -> No longitudinal_path, longitudinal, designs = _project(tmp_path) records = slope_records(longitudinal_path, longitudinal, designs, 0.0, 40.0) route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)] - outline, ticks = slope_pieces(route, records, 0.0, 40.0) - left_edge, left_end, right_edge, right_end = outline["lines"] + edge, fill, cut, ticks = slope_pieces(route, records, 0.0, 40.0) + left_edge, right_edge = edge["lines"] + # 좌(+)가 높은 지반 — 좌측 비탈은 절토 · 우측은 성토, 각각 한 줄로 이어짐 + assert all(r["left_cut"] and not r["right_cut"] for r in records) + (left_end,), (right_end,) = cut["lines"], fill["lines"] for chainage in (20, 40): # 견본 측점 둘 design = {**designs[chainage], "_c": chainage} index = next(i for i, p in enumerate(left_edge) if abs(p[1] - chainage) < 1e-6) @@ -157,3 +165,68 @@ def test_drawing_has_only_chosen_pieces() -> None: assert layer_id("contour") not in layers and layer_id("station") not in layers labels = [e["shapeData"]["label"] for e in drawing["entities"] if e["type"] == "Text"] assert "R=15" in labels and "계획평면도(시험)" in labels + + +def test_split_runs_switch_cut_fill_and_stay_joined() -> None: + route = [(0.0, 0.0, 0.0), (40.0, 0.0, 40.0)] + base = {"left_edge_m": 2.0, "left_end_m": 5.0, "right_edge_m": -2.0, "right_end_m": -5.0} + records = [ + {"chainage_m": 0.0, **base, "left_cut": True, "right_cut": False}, + {"chainage_m": 40.0, **base, "left_cut": False, "right_cut": False}, + ] + _edge, fill, cut, _ticks = slope_pieces(route, records, 0.0, 40.0) + (cut_left,) = cut["lines"] + fill_left = next(line for line in fill["lines"] if line[0][0] < 0) + assert cut_left[-1] == fill_left[0] # 바뀌는 자리에서 두 선이 이어짐 + assert cut_left[0][1] == 0.0 and fill_left[-1][1] == 40.0 + + +def test_default_kinds_keep_old_picture_and_legacy_file(tmp_path: Path) -> None: + root = tmp_path / "project" + outline = {"road_edge", "fill_outline", "cut_outline"} + for ( + kind + ) in DEFAULT_KINDS: # 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음 + assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"] + folder = _kinds_dir(root) + folder.mkdir(parents=True) + (folder / "old.json").write_text( + json.dumps({"name": "옛것", "pieces": ["slope_outline", "station"], "order": 0}), "utf-8" + ) + (kind,) = list_kinds(root) + assert set(kind["pieces"]) == outline | {"station"} + + +def test_update_kind_default_and_release_confirmed(tmp_path: Path) -> None: + root = tmp_path / "project" + manifest = _read_manifest(root) + manifest["drawings"] = { + "plan_terrain": {"confirmed": True}, + "plan_route": {"confirmed": True}, + } + _write_manifest(root, manifest) + update_kind(root, "terrain", "지형2", ["contour", "centerline"]) # 기본 종류 고침 + again = {k["id"]: k for k in list_kinds(root)} # 다시 읽어 같음 + assert again["terrain"] == { + "id": "terrain", + "name": "지형2", + "pieces": ["contour", "centerline"], + } + assert [k["id"] for k in list_kinds(root)][0] == "terrain" # 차례 그대로 + drawings = _read_manifest(root)["drawings"] + assert drawings["plan_terrain"]["confirmed"] is False + assert drawings["plan_route"]["confirmed"] is True + with pytest.raises(ValueError): + update_kind(root, "terrain", "노선배치도", ["contour"]) # 다른 종류와 같은 이름 + with pytest.raises(ValueError): + update_kind(root, "terrain", "지형", []) + with pytest.raises(FileNotFoundError): + update_kind(root, "nokind", "없음", ["contour"]) + + +def test_drainage_symbol_piece() -> None: + pieces = {"drainage": {"items": [{"x": 0.0, "y": 20.0, "label": "D800"}]}} + drawing = build_plan_kind_drawing("plan_k2", "시험", (-50.0, -10.0, 50.0, 50.0), pieces) + mine = [e for e in drawing["entities"] if e.get("layerId") == layer_id("drainage")] + assert {e["type"] for e in mine} >= {"Text"} and len(mine) >= 2 + assert any(e["type"] == "Text" and e["shapeData"]["label"] == "D800" for e in mine) From 3e1186861a58790360e8773b763c225a74a3c76f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 23:09:17 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(B07):=20=EA=B3=84=ED=9A=8D=ED=8F=89?= =?UTF-8?q?=EB=A9=B4=EB=8F=84=20=EC=A2=85=EB=A5=98=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=C2=B7=20=EB=8C=80=ED=94=BC=EC=86=8C=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=20=EC=B8=A1=EC=A0=90=20NO=20+=20=EA=B1=B0?= =?UTF-8?q?=EB=A6=AC=20=C2=B7=20=EA=B3=B5=EC=9A=A9=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EB=B0=95=EC=8A=A4=20(67-1~4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 종류 고르기(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 · 새 종류) → 든 조각 체크 → [업데이트] · 새 종류는 [추가] - 대피소 · 사토장 자리 — B05 구조물 폼의 측점 NO + 거리 두 칸 · 체크박스 · [업데이트] - ui_template_checkbox.ts · .css 신설 — 테마 토큰 색(밝음 · 어두움) - 「더하기」 → 「추가」 · 「자리 더하기」 → 「자리 추가」 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../B07_DesignDetail_Api_Fetch.ts | 14 +++ .../B07_DesignDetail_UI_PlanKinds.ts | 117 ++++++++++-------- .../B07_DesignDetail_UI_Style.css | 35 ++++-- ui_template/ui_template_checkbox.css | 34 +++++ ui_template/ui_template_checkbox.ts | 32 +++++ 5 files changed, 173 insertions(+), 59 deletions(-) create mode 100644 ui_template/ui_template_checkbox.css create mode 100644 ui_template/ui_template_checkbox.ts diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index c7e856856..9f712e219 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -188,6 +188,8 @@ export interface PlanKindsState { pieces: { id: string; name: string }[]; kinds: PlanKind[]; marks: PlanMark[]; + /** 종단 측점 간격(m) — 측점 NO + 거리 입력용(GET 만 · 종단이 없으면 null). */ + station_interval_m?: number | null; } export function fetchPlanKinds(projectId: string): Promise { @@ -205,6 +207,18 @@ export function addPlanKind( }); } +export function updatePlanKind( + projectId: string, + kindId: string, + name: string, + pieces: string[], +): Promise { + return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, { + method: "PUT", + body: JSON.stringify({ name, pieces }), + }); +} + export function deletePlanKind(projectId: string, kindId: string): Promise { return requestJson(`/projects/${projectId}/plan-kinds/${encodeURIComponent(kindId)}`, { method: "DELETE", diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts index 65a634851..7ec37a5f6 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -1,22 +1,29 @@ /* ============================================================================= * B07_DesignDetail_UI_PlanKinds.ts * 좌측 도면 목록의 「계획평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 · - * 사용자 추가)별 도면 단추, 종류 지우기 · 더하기, 대피소 · 사토장 자리 (PLAN 60장). + * 사용자 추가)별 도면 단추, 종류 지우기 · 추가 · 업데이트, 대피소 · 사토장 자리 (PLAN 60 · 67장). * * 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다. + * 체크박스는 공용(`ui_template_checkbox`) · 측점 입력은 B05 구조물 폼과 같은 측점 NO + 거리 두 칸. * ========================================================================== */ +import { createCheckbox } from "@ui/ui_template_checkbox"; import { showToast } from "@ui/ui_template_elements"; +import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields"; import { addPlanKind, deletePlanKind, fetchPlanKinds, savePlanMarks, + updatePlanKind, type DesignDrawingItem, type PlanKindsState, type PlanMark, } from "./B07_DesignDetail_Api_Fetch"; +/** 종단이 없어 측점 간격을 못 받았을 때 — 서버 `infer_station_interval` 기본값과 같음. */ +const DEFAULT_STATION_INTERVAL_M = 20; + export interface PlanKindsControls { projectId: string; /** 종류 · 대피소 자리를 고친 뒤 — 목록과 도면을 다시 받는다. */ @@ -44,30 +51,45 @@ async function run(action: () => Promise, controls: PlanKindsControls): } } -function addKindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { +/** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 [추가]. */ +function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { const form = el("div", "b07-plan-tools__block"); - form.append(el("strong", "", "종류 더하기")); + form.append(el("strong", "", "종류")); + const pick = el("select", "b07-plan-tools__kind"); + pick.append( + ...state.kinds.map((kind) => new Option(kind.name, kind.id)), + new Option("새 종류", ""), + ); const name = el("input", "b07-plan-tools__name"); name.type = "text"; name.placeholder = "종류 이름"; name.maxLength = 40; - const boxes = state.pieces.map((piece) => { - const label = el("label", "b07-plan-tools__piece"); - const box = el("input", ""); - box.type = "checkbox"; - box.value = piece.id; - label.append(box, document.createTextNode(piece.name)); - return { label, box }; - }); + const boxes = state.pieces.map((piece) => createCheckbox(piece.name, { value: piece.id })); const pieceList = el("div", "b07-plan-tools__pieces"); - pieceList.append(...boxes.map((item) => item.label)); - const add = el("button", "b07-plan-tools__button", "더하기"); - add.type = "button"; - add.addEventListener("click", () => { - const pieces = boxes.filter((item) => item.box.checked).map((item) => item.box.value); - void run(() => addPlanKind(controls.projectId, name.value.trim(), pieces), controls); + pieceList.append(...boxes.map((box) => box.root)); + const action = el("button", "b07-plan-tools__button"); + action.type = "button"; + const show = (): void => { + const kind = state.kinds.find((item) => item.id === pick.value); + name.value = kind?.name ?? ""; + boxes.forEach(({ input }) => (input.checked = kind?.pieces.includes(input.value) ?? false)); + action.textContent = kind ? "업데이트" : "추가"; + }; + pick.addEventListener("change", show); + show(); + action.addEventListener("click", () => { + const pieces = boxes.filter((box) => box.input.checked).map((box) => box.input.value); + const kindId = pick.value; + const label = name.value.trim(); + void run( + () => + kindId + ? updatePlanKind(controls.projectId, kindId, label, pieces) + : addPlanKind(controls.projectId, label, pieces), + controls, + ); }); - form.append(name, pieceList, add); + form.append(pick, name, pieceList, action); return form; } @@ -78,28 +100,18 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem el( "p", "b07-plan-tools__hint", - "구조물로 놓은 대피소 · 사토장은 저절로 실립니다. 더 놓을 측점(m)만 적으세요.", + "구조물로 놓은 대피소 · 사토장은 저절로 실립니다. 더 놓을 측점(NO + 거리)만 적으세요.", ), ); + const interval = state.station_interval_m || DEFAULT_STATION_INTERVAL_M; const rows = el("div", "b07-plan-tools__marks"); - const readers = new Map PlanMark>(); + const readers = new Map PlanMark | null>(); const addRow = (mark: PlanMark): void => { const row = el("div", "b07-plan-tools__mark"); - const chainage = el("input", "b07-plan-tools__chainage"); - chainage.type = "number"; - chainage.min = "0"; - chainage.step = "0.1"; - chainage.value = String(mark.chainage_m); - const flag = (text: string, checked: boolean): [HTMLLabelElement, HTMLInputElement] => { - const label = el("label", "b07-plan-tools__piece"); - const box = el("input", ""); - box.type = "checkbox"; - box.checked = checked; - label.append(box, document.createTextNode(text)); - return [label, box]; - }; - const [refugeLabel, refuge] = flag("대피소", mark.refuge); - const [spoilLabel, spoil] = flag("사토장", mark.spoil); + 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 = "이 자리 빼기"; @@ -107,23 +119,28 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem readers.delete(row); row.remove(); }); - row.append(chainage, refugeLabel, spoilLabel, remove); - readers.set(row, () => ({ - chainage_m: Number(chainage.value), - refuge: refuge.checked, - spoil: spoil.checked, - })); + 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", "자리 더하기"); + 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", "자리 저장"); + const save = el("button", "b07-plan-tools__button", "업데이트"); save.type = "button"; save.addEventListener("click", () => { const marks = [...readers.values()].map((read) => read()); - void run(() => savePlanMarks(controls.projectId, marks), controls); + 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); @@ -131,7 +148,7 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem return form; } -/** 「계획평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 더하기 · 대피소 자리. */ +/** 「계획평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 종류 · 대피소 자리. */ export function buildPlanSection( items: DesignDrawingItem[], makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement, @@ -161,9 +178,7 @@ export function buildPlanSection( remove.type = "button"; remove.title = `「${name}」 종류 지우기`; remove.addEventListener("click", () => { - if ( - !window.confirm(`「${name}」 종류를 지웁니다. 다시 쓰려면 종류 더하기로 새로 만듭니다.`) - ) + if (!window.confirm(`「${name}」 종류를 지웁니다. 다시 쓰려면 「새 종류」로 추가합니다.`)) return; void run(() => deletePlanKind(controls.projectId, kindId), controls); }); @@ -174,14 +189,14 @@ export function buildPlanSection( } if (controls) { - // 더하기 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다. + // 종류 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다. const tools = el("details", "b07-plan-tools"); - tools.append(el("summary", "", "종류 더하기 · 대피소 자리")); + tools.append(el("summary", "", "종류 추가 · 업데이트 · 대피소 자리")); tools.addEventListener("toggle", () => { if (!tools.open || tools.dataset.loaded) return; tools.dataset.loaded = "true"; void fetchPlanKinds(controls.projectId) - .then((state) => tools.append(addKindForm(state, controls), marksForm(state, controls))) + .then((state) => tools.append(kindForm(state, controls), marksForm(state, controls))) .catch((error: unknown) => { delete tools.dataset.loaded; showToast( diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index d278b1b24..d098787a4 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -235,7 +235,7 @@ /* 표준도 제원 입력 모양은 B08 로 이관(2026-09-13) — `B08_Quantity_UI_StructureSheet_Spec.ts` 가 스스로 넣음. */ -/* 계획평면도 묶음 — 종류별 이름 줄 + 장 단추, 아래 더하기 · 대피소 자리(PLAN 60장) */ +/* 계획평면도 묶음 — 종류별 이름 줄 + 장 단추, 아래 종류 추가 · 업데이트 · 대피소 자리(PLAN 60 · 67장) */ .b07-plan-kind { display: flex; flex-direction: column; @@ -278,11 +278,9 @@ grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); } -.b07-plan-tools__piece { - display: flex; - align-items: center; - gap: var(--spacing-4); - white-space: nowrap; +.b07-plan-tools__kind, +.b07-plan-tools__name { + min-width: 0; } .b07-plan-tools__hint { @@ -291,9 +289,30 @@ font-size: var(--text-caption); } -.b07-plan-tools__chainage { - width: 72px; +/* 측점 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, diff --git a/ui_template/ui_template_checkbox.css b/ui_template/ui_template_checkbox.css new file mode 100644 index 000000000..c896db75b --- /dev/null +++ b/ui_template/ui_template_checkbox.css @@ -0,0 +1,34 @@ +/* 공용 체크박스(ui_template_checkbox.ts) — 글 한 줄 · 색은 테마 토큰 */ +.ui-checkbox { + display: inline-flex; + align-items: center; + gap: var(--spacing-4); + min-width: 0; + color: var(--color-text-body); + white-space: nowrap; + cursor: pointer; +} + +.ui-checkbox__input { + flex: 0 0 auto; + width: 14px; + height: 14px; + margin: 0; + accent-color: var(--color-primary); + cursor: pointer; +} + +.ui-checkbox__input:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 1px; +} + +.ui-checkbox__text { + overflow: hidden; + text-overflow: ellipsis; +} + +.ui-checkbox:has(.ui-checkbox__input:disabled) { + color: var(--color-text-muted); + cursor: default; +} diff --git a/ui_template/ui_template_checkbox.ts b/ui_template/ui_template_checkbox.ts new file mode 100644 index 000000000..0f8845a31 --- /dev/null +++ b/ui_template/ui_template_checkbox.ts @@ -0,0 +1,32 @@ +/* ============================================================================= + * ui_template_checkbox.ts + * 공용 체크박스 — [□ 글] 한 줄 · 색은 테마 토큰(밝음 · 어두움 같이). 줄 세우기는 부르는 쪽 몫. + * + * const box = createCheckbox("등고선", { checked: true, value: "contour" }); + * panel.append(box.root); // 읽기 = box.input.checked · box.input.value + * ========================================================================== */ + +import "./ui_template_checkbox.css"; + +export interface Checkbox { + root: HTMLLabelElement; + input: HTMLInputElement; +} + +export function createCheckbox( + text: string, + options: { checked?: boolean; value?: string } = {}, +): Checkbox { + const root = document.createElement("label"); + root.className = "ui-checkbox"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.className = "ui-checkbox__input"; + input.checked = options.checked ?? false; + if (options.value !== undefined) input.value = options.value; + const caption = document.createElement("span"); + caption.className = "ui-checkbox__text"; + caption.textContent = text; + root.append(input, caption); + return { root, input }; +}