diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 0319b43d2..cc12ad8eb 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -177,6 +177,8 @@ export const STATE_REGISTRY = { crossdesign: { bucket: "draft", scope: "route" }, /** 설계선 쉬프트 = 쪽마다 노견 확장 `{측점: {left, right}}`(139-6) — [저장] 때 patch 로. */ shoulderext: { bucket: "draft", scope: "route" }, + /** 소단 쪽마다 옮긴 깊이 `{측점: {left, right}}`(194-1 · 횡단도 ▲▼) — [저장] 때 patch 로. */ + bermdepth: { bucket: "draft", scope: "route" }, /** B08 자재 집계표 셋 사용자 값 `{units, mix, surcharge}`(고친 칸만) — [저장] 때 프로젝트 설정으로(121). */ "b08-materials": { bucket: "draft", scope: "project" }, /** B08 홍수량 계산서 입력 `{측점: 입력}` — [저장] 때 B08 자기 파일로 · B05 연결값과 같은 칸은 null(연동 · 96). */ diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 1666ba773..88f8e135f 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -455,6 +455,9 @@ export interface CrossDesign { /** 설계선 쉬프트 = 쪽마다 노견 확장(m · ≥ 0 · 139-6) — 사용자 값 · 노체 · 측구 · 사면 · 구조물 같이. */ shoulder_extend_left_m?: number; shoulder_extend_right_m?: number; + /** 소단 쪽마다 옮긴 깊이(노면 끝 아래 m · 194-1) — 사용자 값 · 기하 입력. */ + berm_depth_left_m?: number; + berm_depth_right_m?: number; cross_slope_pct: number; ditch: | { type: "standard"; top_width_m: number; bottom_width_m: number; depth_m: number } @@ -578,6 +581,9 @@ export interface CrossSectionPatch { /** 설계선 쉬프트 = 쪽마다 노견 확장(m · 139-6) — 0 = 되돌림. */ shoulder_extend_left_m?: number; shoulder_extend_right_m?: number; + /** 소단 쪽마다 옮긴 깊이(m · 194-1 · 횡단도 ▲▼). */ + berm_depth_left_m?: number; + berm_depth_right_m?: number; /** 배수관 연장(m) — 구조물 면적과 같은 길로 정본에 실린다(2026-09-08). */ pipe_length_m?: number; /** B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 관 길이와 같은 길. */ diff --git a/B06_Section/B06_Section_Cross_Design_Session.ts b/B06_Section/B06_Section_Cross_Design_Session.ts index 2f38122b6..d351a1f6b 100644 --- a/B06_Section/B06_Section_Cross_Design_Session.ts +++ b/B06_Section/B06_Section_Cross_Design_Session.ts @@ -74,15 +74,14 @@ export function crossDesignChoices( export type ShoulderSide = "left" | "right"; export type ShoulderExtends = Map>>; -/** 누가거리(숫자) → 쪽마다 노견 확장(세션에 있는 쪽만). */ -export function readShoulderExtends( - projectId: string | null, - routeId: number | null, -): ShoulderExtends { +type SideState = "shoulderext" | "bermdepth"; + +/** 누가거리(숫자) → 쪽마다 값(세션에 있는 쪽만). */ +function readSides(name: SideState, projectId: string | null, routeId: number | null) { const out: ShoulderExtends = new Map(); if (!projectId || routeId === null) return out; const raw = readState>>>( - "shoulderext", + name, projectId, routeId, ); @@ -93,8 +92,8 @@ export function readShoulderExtends( return out; } -/** 한 측점 한 쪽 노견 확장을 담는다(0 = 되돌림 · 저장 때 정본의 옛 값을 지움). */ -export function writeShoulderExtend( +function writeSide( + name: SideState, projectId: string, routeId: number, chainageM: number, @@ -102,11 +101,34 @@ export function writeShoulderExtend( valueM: number, ): void { const all = - readState>>>( - "shoulderext", - projectId, - routeId, - ) ?? {}; + readState>>>(name, projectId, routeId) ?? + {}; all[keyOf(chainageM)] = { ...(all[keyOf(chainageM)] ?? {}), [side]: Math.max(valueM, 0) }; - writeState("shoulderext", all, projectId, routeId); + writeState(name, all, projectId, routeId); } + +/** 누가거리(숫자) → 쪽마다 노견 확장(세션에 있는 쪽만). */ +export const readShoulderExtends = (projectId: string | null, routeId: number | null) => + readSides("shoulderext", projectId, routeId); + +/** 한 측점 한 쪽 노견 확장을 담는다(0 = 되돌림 · 저장 때 정본의 옛 값을 지움). */ +export const writeShoulderExtend = ( + projectId: string, + routeId: number, + chainageM: number, + side: ShoulderSide, + valueM: number, +) => writeSide("shoulderext", projectId, routeId, chainageM, side, valueM); + +/* ── 소단 쪽마다 옮긴 깊이(노면 끝 아래 m · ≥ 0 · 194-1 · 횡단도 ▲▼) — 노견 확장과 같은 꼴. + * [저장]·[확정] 이 patch 로 싣고(서버가 다시 셈), 브라우저 재계산이 입력으로 씀. */ +export const readBermDepths = (projectId: string | null, routeId: number | null) => + readSides("bermdepth", projectId, routeId); + +export const writeBermDepth = ( + projectId: string, + routeId: number, + chainageM: number, + side: ShoulderSide, + depthM: number, +) => writeSide("bermdepth", projectId, routeId, chainageM, side, depthM); diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index fd6f8548e..02bfa8a79 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -31,7 +31,11 @@ import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetc import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; import type { BermSessionSpec } from "./B06_Section_UI_Page_Persist"; import type { BermSpec } from "@util/common_util_cross_berm"; -import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session"; +import { + crossDesignChoices, + readBermDepths, + readShoulderExtends, +} from "./B06_Section_Cross_Design_Session"; import { effectiveStandardCross, readRockBoundaryDefault, @@ -95,6 +99,9 @@ export const USER_TOUCHED_KEYS = [ // 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김 "shoulder_extend_left_m", "shoulder_extend_right_m", + // 소단 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼) — 기하 입력이라 재계산에도 넘김 + "berm_depth_left_m", + "berm_depth_right_m", ] as const; /** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */ @@ -192,17 +199,39 @@ function bermPayload( projectId: string, routeId: number, detail: SectionDetailResponse, -): Record { +): Record>> { const spans = readBermSpans(projectId, routeId); - const out: Record = {}; + const out: Record>> = {}; if (!spans.length) return out; + const depthAt = bermDepthReader(projectId, routeId); for (const section of detail.cross_sections) { const spec = bermSpecAt(spans, section.chainage_m); - if (spec) out[rockKey(section.chainage_m).toFixed(2)] = spec; + if (!spec) continue; + const design = (section.design ?? {}) as Record; + const sides: Partial> = {}; + for (const side of SIDES) { + const own = depthAt(section.chainage_m, design, side); + if (own !== null) sides[`berm_depth_${side}_m`] = own; + } + out[rockKey(section.chainage_m).toFixed(2)] = { ...spec, ...sides }; } return out; } +type Side = "left" | "right"; +const SIDES: Side[] = ["left", "right"]; + +/** 소단 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 순 · 없으면 null(구간 깊이를 씀). */ +function bermDepthReader(projectId: string, routeId: number) { + const session = readBermDepths(projectId, routeId); + return (chainageM: number, design: Record, side: Side): number | null => { + const own = session.get(rockKey(chainageM))?.[side]; + const stored = design[`berm_depth_${side}_m`]; + const value = own ?? (typeof stored === "number" ? stored : null); + return value === null ? null : Math.max(value, 0); + }; +} + /** * 측점별 암 절토 경사비 세션값 — 암 경계선(`readRockOffsets`)과 같은 방식으로 읽는다. * @@ -256,7 +285,8 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { // 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는 // 순간 계단이 사라진다(계획서 3-9). const bermSpans = readBermSpans(projectId, input.routeId); - const bermAt = (chainageM: number): BermSpec | null => { + const bermDepthAt = bermDepthReader(projectId, input.routeId); + const bermAt = (chainageM: number, design: Record): BermSpec | null => { const spec = bermSpecAt(bermSpans, chainageM); return spec ? { @@ -264,6 +294,9 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { intervalM: spec.interval_m, slopeDeg: spec.slope_deg, depthM: spec.depth_m, + // 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 + depthLeftM: bermDepthAt(chainageM, design, "left"), + depthRightM: bermDepthAt(chainageM, design, "right"), } : null; }; @@ -325,7 +358,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { (typeof design.ditch_type === "string" ? design.ditch_type : "standard"), paved: pavedBySpecs(section), standard, - berm: bermAt(section.chainage_m), + berm: bermAt(section.chainage_m, design), rockBoundaryOffsetM, cutSlopeRatio: cutSlopeAt(section.chainage_m, design), twoStageSlope: @@ -374,6 +407,11 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { ); section.design.shoulder_extend_left_m = extendLeft; section.design.shoulder_extend_right_m = extendRight; + // 옮긴 소단 깊이 = 새 계산이 되실은 값(옛 값 승계보다 세션이 새것 · 194-1) + for (const side of SIDES) { + const own = next[`berm_depth_${side}_m`]; + if (typeof own === "number") section.design[`berm_depth_${side}_m`] = own; + } updated.push(section.chainage_m); } return updated; diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 885293480..db44611ca 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -541,6 +541,10 @@ def compute_cross_design( "slope_deg": round(float(berm.slope_deg), 4), "depth_m": round(max(float(berm.depth_m), 0.0), 4), } + # 쪽마다 옮긴 깊이(194-1) — 옮긴 쪽만 되싣는다(사용자 값 · 다시 셀 때 입력) + for side, own in (("left", berm.depth_left_m), ("right", berm.depth_right_m)): + if own is not None: + result[f"berm_depth_{side}_m"] = round(max(float(own), 0.0), 4) # 노견 확장(139-6) — 넓힌 쪽만 되싣는다(사용자 값 · 다시 셀 때 입력) for side, extend in ( ("left", geometry.shoulder_extend_left), diff --git a/B06_Section/B06_Section_Engine_Design_Geometry.py b/B06_Section/B06_Section_Engine_Design_Geometry.py index db48dd7c9..1bc4121df 100644 --- a/B06_Section/B06_Section_Engine_Design_Geometry.py +++ b/B06_Section/B06_Section_Engine_Design_Geometry.py @@ -21,6 +21,7 @@ from common_util.common_util_cross_berm import ( BermSpec, cut_profile_points, fill_profile_points, + side_berm, ) from common_util.common_util_cross_berm import elevation_at as berm_elevation_at @@ -236,7 +237,9 @@ class _SectionGeometry: edge_z = ( self.road_z(self.left_extent) if side == "left" else self.road_z(-self.right_extent) ) - points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm, top_z=edge_z) + # 쪽마다 옮긴 깊이(194-1) — `side_berm` + berm = side_berm(self.berm, side) + points = fill_profile_points(start_dist, start_z, self.fill_ratio, berm, top_z=edge_z) self._fill_points_cache[side] = points return points diff --git a/B06_Section/B06_Section_Engine_Structures_Wall.py b/B06_Section/B06_Section_Engine_Structures_Wall.py index af7ae6052..90649e6fc 100644 --- a/B06_Section/B06_Section_Engine_Structures_Wall.py +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -120,6 +120,70 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]: return [] +def load_berm_spans(project_root: Path) -> list[tuple[float, float, dict[str, float]]] | None: + """구조물 정본의 소단 구간 `[(시작, 끝, 제원)]` — [저장] 서버 다시 셈의 소단 원천(194-1). + + 짝: TS `bermSpansFromStructures`(같은 규칙 — 폭 0 = 없음 · 깊이 빈 값 = 기본 1.0 · 0 미만 = 0). + 못 읽으면 None(부르는 쪽이 저장분 `design.berm` 으로 물러섬). + """ + from common_util.common_util_cross_berm import ( + BERM_DEFAULT_DEPTH_M, + BERM_DEFAULT_INTERVAL_M, + BERM_DEFAULT_SLOPE_DEG, + BERM_DEFAULT_WIDTH_M, + ) + + def number(options: dict[str, Any], key: str, fallback: float) -> float: + try: + value = float(options.get(key)) + except (TypeError, ValueError): + return fallback + return value if math.isfinite(value) and value >= 0 else fallback + + try: + spans: list[tuple[float, float, dict[str, float]]] = [] + for item in load_structures(str(project_root))[1]: + if item.type_id != BERM_TYPE_ID: + continue + anchor = item.chainage_m if item.chainage_m is not None else item.start_m + start = item.start_m if item.start_m is not None else anchor + end = item.end_m if item.end_m is not None else anchor + if start is None or end is None: + continue + options = item.options or {} + width = number(options, "width_m", BERM_DEFAULT_WIDTH_M) + if width <= 0: + continue + raw = options.get("depth_m") + try: + depth = BERM_DEFAULT_DEPTH_M if raw in (None, "") else max(float(raw), 0.0) + except (TypeError, ValueError): + depth = BERM_DEFAULT_DEPTH_M + if not math.isfinite(depth): + depth = BERM_DEFAULT_DEPTH_M + spec = { + "width_m": width, + "interval_m": number(options, "interval_m", BERM_DEFAULT_INTERVAL_M), + "slope_deg": number(options, "slope_deg", BERM_DEFAULT_SLOPE_DEG), + "depth_m": depth, + } + spans.append((float(min(start, end)), float(max(start, end)), spec)) + return spans + except Exception: # noqa: BLE001 — 정본을 못 읽으면 저장분으로 물러섬 + logger.exception("B06 소단 구간을 읽지 못했습니다 (저장분 소단을 씀)") + return None + + +def berm_span_at( + spans: list[tuple[float, float, dict[str, float]]], chainage_m: float +) -> dict[str, float] | None: + """그 측점을 덮는 소단 제원 — 겹치면 먼저 놓은 것(TS `bermSpecAt` 짝).""" + for start, end, spec in spans: + if start - 1e-6 <= chainage_m <= end + 1e-6: + return spec + return None + + def attach_wall_structures(project_root: Path, cross_sections: list[dict[str, Any]]) -> int: """구간 안 측점의 횡단 dict에 `revetment` 키를 얹는다. 얹은 개수를 돌려준다. diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 6a06c5481..adab8a3c5 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -160,6 +160,9 @@ USER_TOUCHED_KEYS = ( # 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력(`stored_shoulder_extend`) "shoulder_extend_left_m", "shoulder_extend_right_m", + # 소단 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼) — 기하 입력(`stored_berm`) + "berm_depth_left_m", + "berm_depth_right_m", ) # 노선 [확인] 체인이 옛 노선에서 누가거리로 넘기는 **사용자 입력**(PLAN 74-3) — 계획고 · 면적 · @@ -256,14 +259,20 @@ def enforce_design_lines( 받아 적었다(CLAUDE.md 5장 「[저장]은 서버가 정본으로 다시 계산」 어긋남 · B07 이 저장된 선을 그림). 입력 = 저장분(지반유형 · 단면유형 · 측구 · 포장 · 암 경계 · 암 절토 경사 · 소단 · 노견 확장) + 지금 종단 계획고 · 세월교 월류 하강 · 곡선부 확폭. 사용자 조작값은 승계. + 소단 제원은 구조물 정본(`structures.json`)의 구간이 원천 — 쪽마다 옮긴 깊이는 측점 patch(194-1). """ + from B06_Section.B06_Section_Engine_Structures_Wall import berm_span_at, load_berm_spans + ford_drops = ford_surface_drops(project_root) + spans = load_berm_spans(project_root) changed = 0 for section in cross_sections: design = section.get("design") if not isinstance(design, dict): continue chainage = float(section.get("chainage_m", 0.0)) + if spans is not None: # 구간을 지웠으면 소단 없음 · 못 읽으면 저장분 그대로 + design = {**design, "berm": berm_span_at(spans, chainage)} try: recomputed = compute_cross_design( section.get("samples", []), @@ -449,22 +458,34 @@ def stored_cut_slope(stored: dict[str, Any]) -> float | None: return None -def stored_berm(stored: dict[str, Any]) -> BermSpec | None: - """저장분에 남은 소단 제원 — 세션값이 없을 때 쓴다(확정 뒤·다른 PC).""" - spec = stored.get("berm") +def berm_from(spec: Any, sides: dict[str, Any]) -> BermSpec | None: + """소단 제원 dict + 쪽마다 옮긴 깊이(`berm_depth_{side}_m` · 194-1) → BermSpec.""" if not isinstance(spec, dict): return None + + def own(side: str) -> float | None: + value = sides.get(f"berm_depth_{side}_m") + ok = isinstance(value, (int, float)) and not isinstance(value, bool) + return max(float(value), 0.0) if ok else None + try: return BermSpec( width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)), interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)), slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)), depth_m=max(float(spec.get("depth_m", BERM_DEFAULT_DEPTH_M)), 0.0), + depth_left_m=own("left"), + depth_right_m=own("right"), ) except (TypeError, ValueError): return None +def stored_berm(stored: dict[str, Any]) -> BermSpec | None: + """저장분에 남은 소단 제원 + 쪽마다 옮긴 깊이 — 세션값이 없을 때 쓴다(확정 뒤·다른 PC).""" + return berm_from(stored.get("berm"), stored) + + def recompute_designs_for_alignment( longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]], @@ -500,13 +521,10 @@ def recompute_designs_for_alignment( session_berms: dict[float, BermSpec] = {} for raw_key, spec in (berms or {}).items(): try: - session_berms[round(float(raw_key), 3)] = BermSpec( - width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)), - interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)), - slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)), - depth_m=max(float(spec.get("depth_m", BERM_DEFAULT_DEPTH_M)), 0.0), - ) - except (TypeError, ValueError, AttributeError): + parsed = berm_from(spec, spec if isinstance(spec, dict) else {}) # 쪽 깊이도(194-1) + if parsed is not None: + session_berms[round(float(raw_key), 3)] = parsed + except (TypeError, ValueError): continue for section in cross_sections: chainage = float(section.get("chainage_m", 0.0)) diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index b2d086b59..d0ee0e92c 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -184,6 +184,9 @@ class CrossSectionPatch(BaseModel): # 설계선 쉬프트 = 쪽마다 노견 확장(m · 139-6) — 0 = 되돌림(정본의 옛 값을 지움) shoulder_extend_left_m: float | None = Field(default=None, ge=0, le=20) shoulder_extend_right_m: float | None = Field(default=None, ge=0, le=20) + # 소단 쪽마다 옮긴 깊이(노면 끝 아래 m · 194-1 · 횡단도 ▲▼) — 0 = 노면 끝 높이(도로 위 금지) + berm_depth_left_m: float | None = Field(default=None, ge=0, le=50) + berm_depth_right_m: float | None = Field(default=None, ge=0, le=50) inlet_structure: Literal["auto", "revet", "I", "L", "U"] | None = None basin_adjust: BasinAdjustPatch | None = None # 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…). diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 5020be6bb..7bc478776 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -116,6 +116,8 @@ export interface CrossDesignChange { ditch_choice: boolean | null; /** 설계선 쉬프트(139-6) — 있으면 그 쪽 노견 확장만 바꾸는 변경(다른 칸은 안 씀). */ shoulder_extend?: { side: "left" | "right"; valueM: number }; + /** 소단 높이(194-1) — 있으면 그 쪽 소단 깊이(노면 끝 아래 m)만 바꾸는 변경. */ + berm_depth?: { side: "left" | "right"; valueM: number }; } /** diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts index b53a16ec9..36575f17f 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -10,11 +10,15 @@ * 고른 조각 쪽 사면을 노견째 이동 패널(◀ ↺ ▶ · 방향키 · 0.1 m)로 옮김(139-6 쉬프트 · 151-3 · * 노폭 안쪽 금지 = 0 에서 멈춤). 축 제약(193-1) — 절 · 성토측 = 그 쪽 바깥 가로만(반대측 고정 · * 구조물은 노견 끝에 서서 한 벌로 감) · 도로측(노면 · 노견) = 높이만(▲▼ = 종단 계획고). + * 소단 수평 단(194) = 그 쪽 깊이만(▲▼ · ◀▶ 막음 · 노면 끝 위 금지) · 좌측 소단 상자 켬. + * 고름은 조각 키(갈래 + 쪽 + 차례)로 기억 — 옮겨 다시 그려도 같은 조각(194-2). * ========================================================================== */ -import type { CrossDesign } from "./B06_Section_Api_Fetch"; +import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch"; +import type { CrossDesignChange } from "./B06_Section_UI_Cross_Design"; import { clipSegment, type XY } from "../A00_Common/b_polygon_clip"; import { sideRole } from "@util/common_util_cross_design_geometry"; +import { BERM_DEFAULT_DEPTH_M } from "@util/common_util_cross_berm"; import { arrowsBelongToProfile, bindArrowKeys, @@ -61,7 +65,8 @@ export type CrossSide = "left" | "right"; type ZoneDesign = Pick< CrossDesign, "road_edges" | "ditch" | "ditch_side" | "ditch_enabled" | "section_mode" ->; +> & + Partial>; /** * offset 자리의 구역 — 도로 = 두 노견 끝 사이 · 절토 = 측구 + 절토 사면 · 성토 = 성토 사면 + @@ -104,8 +109,46 @@ export const pieceZone = (design: ZoneDesign, piece: DesignPiece) => /** 고른 선이 움직이는 축(193-1) — 도로측(노면 · 노견) = 높이(y · 종단 계획고 ▲▼) · * 절토 · 성토측(구조물 트림 선 포함) = 그 쪽 바깥 가로(x · 노견째 · 구조물 한 벌). */ -export const pieceMoveAxis = (design: ZoneDesign, piece: DesignPiece): "x" | "y" => - pieceZone(design, piece).zone === "road" ? "y" : "x"; +export const pieceMoveAxis = (design: ZoneDesign, piece: DesignPiece): MoveAxis => + isBermPiece(design, piece) ? "depth" : pieceZone(design, piece).zone === "road" ? "y" : "x"; +/** x = 사면 가로 · y = 노면 높이(종단 계획고) · depth = 소단 높이(그 쪽 깊이 · 194-1). */ +export type MoveAxis = "x" | "y" | "depth"; + +/** 그 쪽 소단 깊이(노면 끝 아래 m) — 옮긴 값 → 구간 값 → 기본 1.0(194-1). */ +export const bermDepthOf = (design: ZoneDesign, side: CrossSide): number => + design[`berm_depth_${side}_m`] ?? design.berm?.depth_m ?? BERM_DEFAULT_DEPTH_M; + +/** 소단 수평 단인가 — 성토측 · 폭 = 소단 폭 · 안쪽 끝 표고 = 노면 끝 − 깊이(194-1). */ +export function isBermPiece(design: ZoneDesign, piece: DesignPiece): boolean { + const berm = design.berm; + if (!berm || piece.structure) return false; + const { zone, side } = pieceZone(design, piece); + const edge = design.road_edges?.[side]; + if (zone !== "fill" || !edge) return false; + const rise = Math.tan((berm.slope_deg * Math.PI) / 180) * berm.width_m; + const low = Math.min(piece.from.elevation, piece.to.elevation); + const high = Math.max(piece.from.elevation, piece.to.elevation); + return ( + Math.abs(Math.abs(piece.to.offset - piece.from.offset) - berm.width_m) < 0.05 && + Math.abs(low - (edge.elevation_m - bermDepthOf(design, side))) < 0.02 && + high - low <= rise + 0.01 + ); +} + +/** 조각 키 = 갈래 + 쪽 + 차례(가운데에서 바깥으로) — 다시 그려도 안 바뀜(194-2). */ +export function pieceKeys(design: ZoneDesign, pieces: DesignPiece[]): string[] { + const mid = (p: DesignPiece) => Math.abs(p.from.offset + p.to.offset) / 2; + const kinds = pieces.map((p) => { + const { zone, side } = pieceZone(design, p); + return `${isBermPiece(design, p) ? "berm" : zone}${p.structure ? "+s" : ""}:${side}`; + }); + return pieces.map((p, i) => { + const rank = pieces.filter( + (q, j) => kinds[j] === kinds[i] && (mid(q) < mid(p) || (mid(q) === mid(p) && j < i)), + ).length; + return `${kinds[i]}:${rank}`; + }); +} const gap = (a: CrossPoint, b: CrossPoint): number => Math.hypot(a.offset - b.offset, a.elevation - b.elevation); @@ -201,7 +244,7 @@ export function visiblePieces(pieces: DesignPiece[], occluders: CrossPoint[][]): return out; } -/** 치수 — 사면길이 · 수평 · 높이(m) · 구배(사면 1:n · 노면 %). */ +/** 치수 — 길이 · 수평 · 높이(m) · 경사비율(사면 1:n · 노면 %). */ export function pieceMeasure( piece: DesignPiece, zone: CrossZone, @@ -219,23 +262,32 @@ export function pieceMeasure( return { lengthM: Math.hypot(horizontalM, heightM), horizontalM, heightM, grade }; } -/** 두 줄(151-1) — 1줄 = 길이 · 수평 · 2줄 = 높이 · 구배(줄바꿈 글자로 가름). */ +/** 두 줄(151-1 · 195-1) — 1줄 = 길이 · 경사비율 · 2줄 = 수평 · 높이(줄바꿈 글자로 가름). */ export function pieceLabel(design: ZoneDesign, piece: DesignPiece): string { const { zone } = pieceZone(design, piece); const m = pieceMeasure(piece, zone); return ( - `${zone === "road" ? "길이" : "사면길이"} ${m.lengthM.toFixed(2)}m · ` + - `수평 ${m.horizontalM.toFixed(2)}m\n높이 ${m.heightM.toFixed(2)}m · ${m.grade}` + `길이 ${m.lengthM.toFixed(2)}m · 경사비율 ${m.grade}\n` + + `수평 ${m.horizontalM.toFixed(2)}m · 높이 ${m.heightM.toFixed(2)}m` ); } -/* ── 페이지 한 칸 고름 — 측점 + 조각 번호(카드를 다시 그려도 되살림 · 137-3) ───────── */ -let picked: { stationId: string; index: number } | null = null; -export const pickedLineAt = (stationId: string): number | null => - picked?.stationId === stationId ? picked.index : null; +/* ── 페이지 한 칸 고름 — 측점 + 조각 키(카드를 다시 그려도 되살림 · 137-3) ─────────── + * 번호가 아니라 키(`pieceKeys`)로 기억 — 옮겨 다시 그리면 조각 차례가 바뀌어도 같은 조각(194-2). + * 키를 안 적은 측점(카드 밖 시험)은 번호 그대로. */ +let picked: { stationId: string; key: string } | null = null; +const keysByStation = new Map(); +export function pickedLineAt(stationId: string): number | null { + if (picked?.stationId !== stationId) return null; + const keys = keysByStation.get(stationId); + const index = keys ? keys.indexOf(picked.key) : Number(picked.key); + return index >= 0 ? index : null; +} export function setPickedLine(stationId: string, index: number | null): void { - if (index !== null) picked = { stationId, index }; - else if (picked?.stationId === stationId) picked = null; + if (index !== null) { + const keys = keysByStation.get(stationId); + picked = { stationId, key: keys ? (keys[index] ?? "") : String(index) }; + } else if (picked?.stationId === stationId) picked = null; } /* ── 카드 배선 ─────────────────────────────────────────────────────────── */ @@ -270,6 +322,54 @@ export function shiftedExtend(now: number, side: CrossSide, screenDir: -1 | 1, k return Math.max(0, Math.round((now + outward) * 10) / 10); } +/** 소단 높이(194-1) — 쪽마다 깊이(노면 끝 아래 m · ≥ 0) · 고름 켬/끔을 좌측 소단 상자에 알림. */ +export interface BermDepthControl { + set: (side: CrossSide, depthM: number) => void; + focus: (on: boolean) => void; +} + +/** 소단 고름 → 좌측 패널 소단 상자 켬(194-2) — 페이지가 단다(측점 · null = 끔). */ +let bermHost: ((chainageM: number | null) => void) | null = null; +let bermHostAt: number | null = null; +export function setBermPickHost(host: ((chainageM: number | null) => void) | null): void { + bermHost = host; + bermHostAt = null; +} +function tellBermHost(chainageM: number | null): void { + if (chainageM === bermHostAt) return; // 다시 그린 카드의 되살림은 한 번만 + bermHostAt = chainageM; + bermHost?.(chainageM); +} + +/** 카드 → 고름 이동 창구 — 노견 확장(139-6) · 소단 깊이(194-1) 둘 다 카드 버튼과 같은 창구. */ +export function designLineMoves( + section: Pick, + onDesignChange?: (chainageM: number, change: CrossDesignChange) => void, +): { stationId: string; shift?: ShoulderShift; berm?: BermDepthControl } { + const design = section.design; + if (!onDesignChange || !design) return { stationId: section.station_id }; + const send = (change: Partial) => + onDesignChange(section.chainage_m, { + ...(design as unknown as CrossDesignChange), + ...change, + }); + return { + stationId: section.station_id, + shift: { + valueOf: (side) => design[`shoulder_extend_${side}_m`] ?? 0, + set: (side, valueM) => send({ shoulder_extend: { side, valueM } }), + }, + berm: { + set: (side, valueM) => send({ berm_depth: { side, valueM } }), + // 끔은 이 측점이 켠 때만 — 다른 카드 소단을 고르며 옛 카드가 풀릴 때 새 켬을 안 지움 + focus: (on) => { + if (on) tellBermHost(section.chainage_m); + else if (bermHostAt === section.chainage_m) tellBermHost(null); + }, + }, + }; +} + export interface DesignLinePick { pieces: DesignPiece[]; /** 치수 칸 — 카드가 [+ − ⤢] 앞에 붙임 · 고름 없으면 숨김. */ @@ -291,6 +391,10 @@ export function attachDesignLinePick(input: { onPick: (index: number) => void; /** 있으면 고른 조각 쪽 사면을 이동 패널로 옮김(139-6 · 151-3). */ shift?: ShoulderShift; + /** 있으면 고른 소단을 ▲▼ 로 옮김(194-1). */ + berm?: BermDepthControl; + /** 있으면 고름을 조각 키로 기억(194-2 · `pickedLineAt`). */ + stationId?: string; }): DesignLinePick { const { layer, design, x, toDisplayY } = input; const x0 = x(0); @@ -313,6 +417,7 @@ export function attachDesignLinePick(input: { ) .map((el) => pointsOf(el).map(toM)); const pieces = visiblePieces(mergeStraightPieces(runs), occluders); + if (input.stationId) keysByStation.set(input.stationId, pieceKeys(design, pieces)); // 판정 선은 구조물 판정면 **아래** — 겹친 자리는 구조물이 이김. const firstStructureHit = kids.find((el) => @@ -337,9 +442,9 @@ export function attachDesignLinePick(input: { label.className = "b06-cross-card__line-dims"; label.hidden = true; let side: CrossSide | null = null; - let axis: "x" | "y" = "x"; + let axis: MoveAxis = "x"; let active: number | null = null; - const { shift } = input; + const { shift, berm } = input; // 사면 이동 패널(151-3) — B06 공용 조정창 꼴(머리 · 「이동」 줄 십자 · 방향키 ←→). // ✕ = 같은 조각 다시 고름(카드가 풂) · ↺ = 그 쪽 0. @@ -370,8 +475,32 @@ export function attachDesignLinePick(input: { step(1), ), ); - shell.root.append(readout, move.row); - const arrows = bindArrowKeys(shell.root, () => [move.controls]); + // 소단 높이 줄(194-1) — ▲ = 얕게(노면 끝 위 금지 = 0 에서 멈춤) · ▼ = 깊게(사면 끝 앞에서 멈춤) + const lift = makeRow("높이"); + lift.controls.classList.add("b06-structure-panel__buttons"); + const toeZ = (at: CrossSide): number => + Math.min( + ...pieces + .filter((p) => !p.structure && pieceZone(design, p).side === at) + .flatMap((p) => [p.from.elevation, p.to.elevation]), + ); + const deepen = (dir: -1 | 1) => () => { + if (!side || !berm) return; + const now = bermDepthOf(design, side); + const next = Math.max(0, Math.round((now + dir * SHIFT_STEP_M) * 10) / 10); + const edgeZ = design.road_edges?.[side]?.elevation_m; + if (dir > 0 && edgeZ !== undefined && edgeZ - next < toeZ(side) + SHIFT_STEP_M) return; + if (next !== now) berm.set(side, next); + }; + lift.controls.append( + dpadButton("▲", "up", "고른 소단을 0.1 m 위로(노면 끝보다 높을 수 없음)", deepen(-1)), + dpadButton("↺", "reset", "이 쪽 소단 = 구간 기본 깊이", () => { + if (side && berm) berm.set(side, design.berm?.depth_m ?? BERM_DEFAULT_DEPTH_M); + }), + dpadButton("▼", "down", "고른 소단을 0.1 m 아래로", deepen(1)), + ); + shell.root.append(readout, move.row, lift.row); + const arrows = bindArrowKeys(shell.root, () => [move.controls, lift.controls]); // 못 가는 축의 방향키는 먹어 막음(193-1) — 사면 ▲▼ 가 종단 계획고로 새지 않고 · 노면 ◀▶ 는 안 감. // 노면 ▲▼ 는 그대로 종단도(계획고 · 종단 함께)로 넘김. const blockOffAxis = (event: KeyboardEvent): void => { @@ -402,14 +531,27 @@ export function attachDesignLinePick(input: { label.textContent = piece ? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "") : ""; - const open = !!(piece && shift); + const open = !!(piece && (axis === "depth" ? berm : shift)); shell.root.classList.toggle("is-hidden", !open); - move.row.classList.toggle("is-hidden", axis === "y"); - move.controls.classList.toggle("is-hidden", axis === "y"); + // 이동 셋 분리(194-2) — 사면 = 이동 줄(◀▶) · 소단 = 높이 줄(▲▼) · 노면 = 둘 다 숨김(▲▼ 종단) + move.row.classList.toggle("is-hidden", axis !== "x"); + move.controls.classList.toggle("is-hidden", axis !== "x"); + lift.row.classList.toggle("is-hidden", axis !== "depth"); + lift.controls.classList.toggle("is-hidden", axis !== "depth"); + const sideName = side === "left" ? "(좌)" : "(우)"; shell.title.textContent = - axis === "y" ? "노면 높이" : side === "left" ? "사면 이동 (좌)" : "사면 이동 (우)"; + axis === "y" + ? "노면 높이" + : axis === "depth" + ? `소단 높이 ${sideName}` + : `사면 이동 ${sideName}`; readout.textContent = - axis === "y" ? "방향키 ▲▼ = 종단 계획고(종단도 함께)" : `이동 ${extend.toFixed(1)}m`; + axis === "y" + ? "방향키 ▲▼ = 종단 계획고(종단도 함께)" + : axis === "depth" && side + ? `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m` + : `이동 ${extend.toFixed(1)}m`; + berm?.focus(axis === "depth" && !!piece); // 좌측 소단 상자 켬 · 끔(194-2) arrows.detach(); document.removeEventListener("keydown", blockOffAxis); if (open) { diff --git a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts index ce8a899b3..2ceacd67b 100644 --- a/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts +++ b/B06_Section/B06_Section_UI_Cross_FillSlope_Notice.ts @@ -6,6 +6,7 @@ * ⭐ 2026-09-15 — 그 밑에 사면길이 3m 초과 **소단 검토 정보 한 줄**(경고 모양 아님 · 브레인 ③). * ⭐ 138-1 — 「⚠ 포장 권장 · n측점」 줄(B05 법정 경사 제안 · 포장 범위 밖 측점 · 옛 카드 배지 자리). * ⭐ 138-3 — 줄 전체가 카드 칸 위에 고정(sticky) · 알림 없어도 줄 유지(오른쪽 끝 열 수 칸은 부르는 쪽이 붙임). + * ⭐ 196-1 — 항목 = 짧은 이름 + 수 칩 · 긴 설명 = 호버 · 내용 칸 하나 공유(`_Notice_Chips`). * ========================================================================== */ import type { CrossSection } from "./B06_Section_Api_Fetch"; @@ -18,83 +19,65 @@ import { } from "./B06_Section_UI_Cross_FillSlope_Warn"; import { L, stationLabel } from "./B06_Section_UI_Section_Common"; import { pavedBySpecs } from "./B06_Section_Cross_Refresh"; +import { createNoticeChips, type NoticeChipRow } from "./B06_Section_UI_Cross_Notice_Chips"; export interface FillSlopeNotice { root: HTMLElement; - /** 측점 설계가 바뀔 때마다 부른다 — 펼침 상태는 그대로 둔다. */ + /** 측점 설계가 바뀔 때마다 부른다 — 연 칩은 그대로 둔다. */ update: (sections: ReadonlyArray, stationInterval: number) => void; } -function noticeBlock(className: string): { - root: HTMLDetailsElement; - fill: (head: string, rows: FillSlopeWarning[], stationInterval: number) => void; -} { - const root = document.createElement("details"); - root.className = className; - root.hidden = true; - const summary = document.createElement("summary"); - const list = document.createElement("div"); - list.className = "b06-section__notice-list"; - root.append(summary, list); - return { - root, - fill(head, rows, stationInterval) { - root.hidden = !rows.length; - summary.textContent = `${head} · ${rows.length}측점`; - list.replaceChildren( - ...rows.map(({ section, sides }) => { - const button = document.createElement("button"); - button.type = "button"; - const parts = sides.map(({ side, lengthM, open }) => { - const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right"); - return `${label} ${open ? "≥" : ""}${lengthM.toFixed(2)}m`; - }); - button.textContent = - `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`.trim(); - button.dataset.stationId = section.station_id; - return button; - }), - ); - }, - }; +/** 측점 단추 글 — 측점 + 쪽별 길이(포장 권장은 측점만) */ +function rows(warnings: FillSlopeWarning[], stationInterval: number): NoticeChipRow[] { + return warnings.map(({ section, sides }) => { + const parts = sides.map(({ side, lengthM, open }) => { + const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right"); + return `${label} ${open ? "≥" : ""}${lengthM.toFixed(2)}m`; + }); + return { + stationId: section.station_id, + text: `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`.trim(), + }; + }); } export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice { const root = document.createElement("div"); root.className = "b06-section__notice-bar"; - const blocks = document.createElement("div"); - blocks.className = "b06-section__notice-blocks"; - root.append(blocks); - const warn = noticeBlock("b06-section__notice"); - const info = noticeBlock("b06-section__notice b06-section__notice--info"); - const pave = noticeBlock("b06-section__notice"); - pave.root.title = L("B06_Design_Paved_Suggested"); - blocks.append(warn.root, pave.root, info.root); - root.addEventListener("click", (event) => { - const id = (event.target as HTMLElement).closest("button")?.dataset.stationId; - if (id) onPick(id); - }); + const chips = createNoticeChips(onPick); + root.append(chips.root); return { root, update(sections, stationInterval) { - warn.fill( - `⚠ ${FILL_SLOPE_WARN_TEXT}`, - fillSlopeWarnings(sections, fillSlopeLengths), - stationInterval, - ); - pave.fill( - `⚠ ${L("B06_Notice_PaveSuggested")}`, - sections - .filter((section) => section.design?.pavement_suggested && !pavedBySpecs(section)) - .map((section) => ({ section, sides: [] })), - stationInterval, - ); - info.fill( - `ⓘ ${BERM_INFO_TEXT}`, - bermReviewStations(sections, slopeRunLengths), - stationInterval, - ); + chips.update([ + { + key: "fill", + label: L("B06_Notice_FillSlope"), + hint: FILL_SLOPE_WARN_TEXT, + tone: "warn", + rows: rows(fillSlopeWarnings(sections, fillSlopeLengths), stationInterval), + }, + { + key: "pave", + label: L("B06_Notice_PaveSuggested"), + hint: L("B06_Design_Paved_Suggested"), + tone: "warn", + rows: rows( + sections + .filter((section) => section.design?.pavement_suggested && !pavedBySpecs(section)) + .map((section) => ({ section, sides: [] })), + stationInterval, + ), + }, + { + key: "berm", + label: L("B06_Notice_Berm"), + hint: BERM_INFO_TEXT, + tone: "info", + rows: rows(bermReviewStations(sections, slopeRunLengths), stationInterval), + }, + ]); }, }; } diff --git a/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts b/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts new file mode 100644 index 000000000..648236959 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Notice_Chips.ts @@ -0,0 +1,95 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Notice_Chips.ts + * 횡단 알림 줄 칩 꼴(196-1) — 항목 = 짧은 이름 + 측점 수 칩 한 줄 · 긴 설명 = 공용 호버. + * 줄 아래 내용 칸 하나를 같이 씀 — 누른 칩의 측점 목록만 · 같은 칩 다시 누르면 닫힘 · + * 다른 칩 누르면 바뀜(한 번에 하나). 측점 단추 누름 = `onPick`(그 카드로). + * 무엇을 셀지는 부르는 쪽(`_FillSlope_Notice`) — 여기는 그리기만. + * ========================================================================== */ + +import { attachHoverInfo } from "@ui/ui_template_hover_info"; + +export interface NoticeChipRow { + stationId: string; + text: string; +} + +export interface NoticeChipItem { + key: string; + /** 짧은 이름 — 칩 글 = 이름 + 측점 수 */ + label: string; + /** 긴 설명 — 호버 */ + hint: string; + tone: "warn" | "info"; + rows: NoticeChipRow[]; +} + +export interface NoticeChips { + root: HTMLElement; + /** 측점 설계가 바뀔 때마다 — 연 칩은 그대로(측점이 0 이 되면 닫힘) */ + update: (items: NoticeChipItem[]) => void; +} + +export function createNoticeChips(onPick: (stationId: string) => void): NoticeChips { + const root = document.createElement("div"); + root.className = "b06-section__notice-blocks"; + const chips = document.createElement("div"); + chips.className = "b06-section__notice-chips"; + const panel = document.createElement("div"); + panel.className = "b06-section__notice-list"; + panel.hidden = true; + root.append(chips, panel); + let items: NoticeChipItem[] = []; + let openKey: string | null = null; + + const render = (): void => { + const open = items.find((item) => item.key === openKey); + if (!open) openKey = null; + chips.replaceChildren( + ...items.map((item) => { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = `b06-section__notice-chip b06-section__notice-chip--${item.tone}`; + chip.dataset.noticeKey = item.key; + chip.setAttribute("aria-expanded", String(item === open)); + chip.textContent = `${item.label} ${item.rows.length}`; + return chip; + }), + ); + panel.hidden = !open; + panel.classList.toggle("is-info", open?.tone === "info"); + panel.replaceChildren( + ...(open?.rows ?? []).map((row) => { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = row.text; + button.dataset.stationId = row.stationId; + return button; + }), + ); + }; + + chips.addEventListener("click", (event) => { + const key = (event.target as HTMLElement).closest(".b06-section__notice-chip") + ?.dataset.noticeKey; + if (!key) return; + openKey = openKey === key ? null : key; + render(); + }); + panel.addEventListener("click", (event) => { + const id = (event.target as HTMLElement).closest("button")?.dataset.stationId; + if (id) onPick(id); + }); + attachHoverInfo( + chips, + (target) => items.find((item) => item.key === target.dataset.noticeKey)?.hint, + ".b06-section__notice-chip", + ); + + return { + root, + update(next) { + items = next.filter((item) => item.rows.length); + render(); + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index f148b57df..9571b8df4 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -29,7 +29,6 @@ import { appendCrossDesignOverlay, appendPavementOverlay, appendRockBoundaryOverlay, - type CrossDesignChange, type RockBoundaryControl, } from "./B06_Section_UI_Cross_Design"; import { appendCardFooter, appendCardHeader } from "./B06_Section_UI_Cross_Card_Chrome"; @@ -59,6 +58,7 @@ import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure"; import { buildCardPanels, emptyCardCulvertState } from "./B06_Section_UI_Cross_View_Panels"; import { attachDesignLinePick, + designLineMoves, DESIGN_LINE_ATTR, type DesignLinePick, pickedLineAt, @@ -593,17 +593,8 @@ export function createCrossSectionCard( x, toDisplayY, onPick: pickLine, - // 고른 선 쪽 노견 확장(139-6) — 카드 버튼과 같은 창구(세션 · 브라우저 재계산) - shift: onDesignChange - ? { - valueOf: (side) => lineDesign[`shoulder_extend_${side}_m`] ?? 0, - set: (side, valueM) => - onDesignChange(section.chainage_m, { - ...(lineDesign as unknown as CrossDesignChange), - shoulder_extend: { side, valueM }, - }), - } - : undefined, + // 고른 선 쪽 노견 확장(139-6) · 소단 높이(194-1) · 조각 키(194-2) — 카드 버튼과 같은 창구 + ...designLineMoves(section, onDesignChange), }); setLineActive = linePick.setActive; setLineActive(pickedLineAt(section.station_id)); diff --git a/B06_Section/B06_Section_UI_Missing_Stations.ts b/B06_Section/B06_Section_UI_Missing_Stations.ts index 8031db761..ead81240e 100644 --- a/B06_Section/B06_Section_UI_Missing_Stations.ts +++ b/B06_Section/B06_Section_UI_Missing_Stations.ts @@ -14,6 +14,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import { createButton, showToast } from "@ui/ui_template_elements"; +import { attachHoverInfo } from "@ui/ui_template_hover_info"; interface MissingStation { chainage_m: number; @@ -67,8 +68,9 @@ export async function mountMissingStationNotice( .join(" · "); text.textContent = `측점이 없는 구조물 ${missing.length}개 — ${where}` + - (missing.length > 6 ? ` 외 ${missing.length - 6}개` : "") + - ". 이 자리는 횡단도에도 안 서고 수량에서도 빠집니다."; + (missing.length > 6 ? ` 외 ${missing.length - 6}개` : ""); + // 긴 까닭 글 = 호버(196-1) + attachHoverInfo(text, "이 자리는 횡단도에도 안 서고 수량에서도 빠집니다."); box.append(text); if (data.can_create) { diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index 12be73986..18b92f1bd 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -9,7 +9,11 @@ * - `applyPanelToAll` : 표준 횡단면 설정 [전체 반영] * ========================================================================== */ -import { writeCrossDesignChoice, writeShoulderExtend } from "./B06_Section_Cross_Design_Session"; +import { + writeBermDepth, + writeCrossDesignChoice, + writeShoulderExtend, +} from "./B06_Section_Cross_Design_Session"; import { showToast } from "@ui/ui_template_elements"; import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh"; import { @@ -74,6 +78,14 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { await reconcileStaleDesigns({ force: true }); return; } + // 소단 높이(194-1) — 그 쪽 깊이만 세션에 · 캐시에 바로 · 브라우저 재계산(같은 창구) + const berm = change.berm_depth; + if (berm) { + writeBermDepth(projectId, currentRouteId, chainageM, berm.side, berm.valueM); + if (target.design) target.design[`berm_depth_${berm.side}_m`] = berm.valueM; + await reconcileStaleDesigns({ force: true }); + return; + } // (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체. if (target.design) { diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 8959cf87b..0d99b5e12 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -29,7 +29,11 @@ import { STRUCTURE_ROW_KEYS, structureAreaRows, } from "./B06_Section_Structure_Layouts"; -import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session"; +import { + crossDesignChoices, + readBermDepths, + readShoulderExtends, +} from "./B06_Section_Cross_Design_Session"; import { readStructureNotes } from "./B06_Section_UI_Cross_Detail"; import { readDetailTables } from "./B06_Section_UI_Cross_Detail_Table"; import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; @@ -420,6 +424,12 @@ export function collectSectionEdits(ctx: SectionPersistContext): { if (sides.left !== undefined) patch.shoulder_extend_left_m = sides.left; if (sides.right !== undefined) patch.shoulder_extend_right_m = sides.right; }); + // 소단 쪽마다 옮긴 깊이(194-1) — 서버가 [저장] 때 구조물 정본의 소단 구간 + 이 값으로 다시 냄 + readBermDepths(ctx.projectId, ctx.routeId()).forEach((sides, chainage) => { + const patch = patchFor(chainage); + if (sides.left !== undefined) patch.berm_depth_left_m = sides.left; + if (sides.right !== undefined) patch.berm_depth_right_m = sides.right; + }); if (detail) { // 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산한다 // (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). diff --git a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts index 6ce5b40a9..fc8ef4475 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts @@ -9,6 +9,8 @@ import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; import type { RevetPick } from "./B06_Section_UI_Cross_Culvert"; import type { B06StructuresPanel } from "./B06_Section_UI_Page_Structures_Panel"; +import { setBermPickHost } from "./B06_Section_UI_Cross_Design_Pick"; +import { BERM_TYPE_ID } from "./B06_Section_UI_Page_Persist"; /** * 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·BOX 선택이 일어나면 그 @@ -164,6 +166,28 @@ export function wireStructureSelection( pickBox(born); }); + // 횡단도 소단 수평 단 고름 → 좌측 폼에 그 소단 · 「상세 내용」 상자 켬(194-2 · 191 상자 고름 짝) + const BERM_BOX = "fieldset.b05-structure__optgroup"; + setBermPickHost((chainageM) => { + panel.root + .querySelectorAll(`${BERM_BOX}.is-active`) + .forEach((box) => box.classList.remove("is-active")); + if (chainageM === null) return; + const berm = panel.getStructures().find((item) => { + if (item.type_id !== BERM_TYPE_ID) return false; + const start = item.start_m ?? item.chainage_m ?? -1; + const end = item.end_m ?? item.chainage_m ?? -1; + return chainageM >= Math.min(start, end) - 1e-6 && chainageM <= Math.max(start, end) + 1e-6; + }); + if (!berm?.structure_id) return; + panel.selectStructure(berm.structure_id); + const box = [...panel.root.querySelectorAll(BERM_BOX)].find( + (el) => !el.closest("[hidden]"), + ); + box?.classList.add("is-active"); + box?.scrollIntoView({ block: "nearest" }); + }); + const origFord = stationControls.ford.select; stationControls.ford.select = (chainageM, role) => { const nested = wallSelecting; diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 0effa7db6..8a0d5fdd7 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -142,10 +142,6 @@ min-width: 0; } -.b06-section__notice-blocks > .b06-section__notice:last-of-type { - margin-bottom: 0; -} - /* 열 수 칸 — 라벨 왼쪽 · 한 줄(150-1) */ .ui-field.b06-section__columns { flex: none; @@ -168,33 +164,48 @@ display: none; } -/* 펼친 목록이 길면 줄 안에서 스크롤 — 고정 줄이 카드 칸을 다 덮지 않게 */ -.b06-section__notice-list { - max-height: 30vh; - overflow: auto; +/* 알림 칩 한 줄(196-1) — 짧은 이름 + 수 · 긴 설명 = 호버 · 누른 칩 = 눌림 꼴 */ +.b06-section__notice-chips { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); } -/* 성토사면 5m 초과 경고 줄(2026-09-14) — 요약 한 줄, 펼치면 측점 단추 목록. */ -.b06-section__notice { - margin-bottom: var(--spacing-8); +.b06-section__notice-chip { + padding: 0 var(--spacing-8); + border: 1px solid currentColor; + border-radius: var(--radius-sm); + background: transparent; color: var(--color-warning); font-size: var(--text-caption); -} - -/* 소단 검토 — 정보 한 줄(경고 색 아님 · 2026-09-15 브레인 ③). */ -.b06-section__notice--info { - color: var(--color-text-secondary); -} - -.b06-section__notice > summary { + white-space: nowrap; cursor: pointer; } +/* 소단 검토 — 정보(경고 색 아님 · 2026-09-15 브레인 ③). */ +.b06-section__notice-chip--info, +.b06-section__notice-list.is-info { + color: var(--color-text-secondary); +} + +.b06-section__notice-chip[aria-expanded="true"] { + background: var(--color-surface-raised); + font-weight: 600; +} + +/* 내용 칸 하나(누른 칩의 측점 목록) — 길면 줄 안에서 스크롤 · 고정 줄이 카드 칸을 다 덮지 않게 */ .b06-section__notice-list { display: flex; flex-wrap: wrap; gap: var(--spacing-4); + max-height: 30vh; + overflow: auto; padding-top: var(--spacing-4); + color: var(--color-warning); +} + +.b06-section__notice-list[hidden] { + display: none; } .b06-section__notice-list > button { diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css index f4fee5c15..778418876 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -223,7 +223,7 @@ background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); } -/* 고른 보라 선 치수(137-2) — 카드 폭 무관 늘 [+ − ⤢] 왼쪽(179-1) · 두 줄(151-1 · 길이 · 수평 / 높이 · 구배). */ +/* 고른 보라 선 치수(137-2) — 카드 폭 무관 늘 [+ − ⤢] 왼쪽(179-1) · 두 줄(151-1 · 길이 · 경사비율 / 수평 · 높이). */ .b06-cross-card__line-dims { align-self: center; padding: 1px var(--spacing-4); diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 85f66877f..3ac853296 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -595,7 +595,7 @@ def pipe_summary( notes = [] missing = sheet.unlinked | {key for key, found in others.items() if not found} if missing: - notes.append(f"조합 사본 없는 시설 {len(missing)}곳 — 「미연결」 줄을 눌러 조합 연결") + notes.append(f"조합 사본 없는 시설 {len(missing)}곳 — 노랑 램프 줄을 눌러 조합 연결") if no_length: notes.append(f"관 연장 없음 {no_length}곳 — B06 횡단 [저장] 뒤 채워짐") if no_basin: diff --git a/B08_Quantity/B08_Quantity_Summary_Data.ts b/B08_Quantity/B08_Quantity_Summary_Data.ts index 3784dd949..116aecc9b 100644 --- a/B08_Quantity/B08_Quantity_Summary_Data.ts +++ b/B08_Quantity/B08_Quantity_Summary_Data.ts @@ -5,8 +5,8 @@ * 줄 = 구조물 · 배관이 놓인 측점(같은 측점은 한 줄 · 오름차순) * 열 = 저장된 구조물 조합(본체 `summary_item` + 그룹 `group_items`) — 같은 층 · 주인 · 키는 한 열 * 조합 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · 서버 `linked_snapshots` · PLAN 143-1) - * 미연결 시설 = 서버 `unlinked`(셈한 칸 기준) — 그 측점도 줄(값 빈칸 · 「미연결」 · PLAN 154-1) - * 「미지정」 열 글 · 같은 조합 열 칸 = 빈 조합 칸(`unassignedText` · `unassignedCombos` · 160-2) + * 미연결 시설 = 서버 `unlinked`(셈한 칸 기준) — 그 측점도 줄(값 빈칸 · 노랑 램프 · PLAN 154-1 · 198-1) + * 램프 호버 글 = 빈 조합 칸(`unassignedText` · 160-2) * * 읽기만 — B05/B06 저장 자료(structures.json · pipe_points.json)를 고치지 않는다. * 런타임 import 는 `common_util_summary_item` 하나 — 시험이 두 파일을 이어 붙여 돌림. @@ -114,7 +114,7 @@ export interface ComboColumn { export const comboId = (item: SummaryItem): string => `${item.layer}|${item.owner ?? ""}|${item.key}`; -/** 측점 「미지정」 칸 글 — 시설마다 「이름: 빈 칸 · 빈 칸」(칸 이름 = 그룹 · 본체 = `body` · 160-2). */ +/** 측점 램프 호버 글 —시설마다 「이름: 빈 칸 · 빈 칸」(칸 이름 = 그룹 · 본체 = `body` · 160-2). */ export const unassignedText = (facilities: readonly UnlinkedFacility[], body: string): string => facilities .map((f) => { @@ -123,17 +123,6 @@ export const unassignedText = (facilities: readonly UnlinkedFacility[], body: st }) .join(" / "); -/** 빈 칸에 고른 조합 id — 같은 조합 열이 있으면 그 칸에 「미지정」(160-2). */ -export function unassignedCombos(facilities: readonly UnlinkedFacility[]): Set { - const ids = new Set(); - for (const f of facilities) - for (const slot of f.slots) { - const item = slot.linked ? null : readSummaryItem(slot.item); - if (item) ids.add(comboId(item)); - } - return ids; -} - function stationOf(thing: PlacedItem): number | null { const value = thing.chainage_m ?? thing.start_m; return typeof value === "number" && Number.isFinite(value) ? value : null; diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index 4345422f6..da26b1d54 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -234,6 +234,7 @@ function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number) frozen: 1, total: totalRow(table), caption: "토적표", + fillViewport: true, }); grid.root.dataset.b08Earthwork = "table"; host.replaceChildren(tableCard(info, grid.root)); diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts index ba97716f5..420100cc8 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -41,6 +41,7 @@ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable) })), frozen: 1, caption: "토공집계표", + fillViewport: true, }); host.replaceChildren(tableCard([info], grid.root)); } @@ -91,7 +92,14 @@ function haulSheet( `${i + 1}) ${label} 평균운반거리: ${fmt(list.ql)} ÷ ${fmt(list.q)} = ${fmt(list.average_m)} M`, ), ); - const grid = createDataTable({ columns, rows, frozen: 1, total, caption: info }); + const grid = createDataTable({ + columns, + rows, + frozen: 1, + total, + caption: info, + fillViewport: true, + }); host.replaceChildren(tableCard([note(info), ...haulNote(table)], grid.root, averages)); } diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index f569f58be..5316e7f84 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -366,6 +366,7 @@ export async function renderMaterialSheet( frozen: built.frozen, empty: "", caption: BASIS, + fillViewport: true, editable: (i, key) => built.editable(i, key), onEdit: (i, key, text) => { const value = parse(text); diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index b419ee0de..f1c338629 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -44,6 +44,18 @@ overflow-wrap: anywhere; } +/* 구조물 집계표 미연결 램프(198-1) — 측점 칸 왼쪽 경고 노랑 점 하나(177-1 미연결 노랑 토큰) · 연결 줄은 없음 */ +[data-b08-summary="table"] td.b08-summary__lamp::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + margin-right: 6px; + border-radius: 50%; + background: var(--color-warning); + vertical-align: middle; +} + /* 좌측 패널 옵션(122) — 연암 · 경암 두 칸 나란히 · 설명 한 줄 */ .b08-options__row { display: grid; diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index 5a25baa8f..b54932b76 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -6,8 +6,8 @@ * 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만) * 머리 두 줄 = 조합 구분(경로 첫 칸) · 조합 이름(나머지 경로) — 사본 manifest 경로(집계 응답 `linked`) * 조합 · 측점 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · PLAN 143-1) — 미연결 시설은 그 측점 - * 줄만 「· 미연결」(값 빈칸 · 서버 `unlinked` · PLAN 154-1) · 측점 옆 「미지정」 열 = 그 측점 빈 조합 칸 - * · 같은 조합 열이 있으면 그 칸에도 「미지정」(160-2 · 화면만) + * 줄만 측점 칸 왼쪽 노랑 램프(값 빈칸 · 서버 `unlinked` · PLAN 154-1 · 198-1) + * · 램프 호버(공용 `ui_template_hover_info`) = 그 측점 빈 조합 칸(160-2 · 화면만) * 횡단배수 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98 · 120) — 머리 구조물 · 형식 · 규격 * 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 서버가 펼친 종류(`types`)는 * 조합 열에서 뺌 · 다른 구조물 열은 조합 그대로 @@ -24,6 +24,7 @@ import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; +import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { pipeStructureId } from "@ui/ui_template_item_picker_project"; import { stationLabel } from "../common_util/common_util_station"; import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item"; @@ -39,7 +40,6 @@ import { type LinkedSnapshot, type PlacedItem, type UnlinkedFacility, - unassignedCombos, unassignedText, unlinkedAt, widthOf, @@ -98,6 +98,9 @@ async function fetchPipeSummary(projectId: string): Promise { } const PIPE_TOP = "관 공"; +/** 미연결 램프 칸 class(`B08_Quantity_UI_Page.css` · 198-1) · 램프 자리 폭(12px 글 기준) */ +const LAMP_CLASS = "b08-summary__lamp"; +const LAMP_W = 14; const stationKey = (station: number): number => Math.round(station * 100); /** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */ const placesOf = (values: number[]): number => @@ -150,29 +153,14 @@ function buildTable( ? { station: L("B08_Quantity_Summary_Total"), ...pipe.total } : null; const labels = stations.map((station) => stationLabel(station, interval)); - // 미연결 시설 측점 = 「· 미연결」(값 빈칸 · 누르면 바로 연결 · 154) - const shown = stations.map((station, i) => - unlinkedAt(unlinked, station).length - ? `${labels[i]} · ${L("B08_Quantity_Summary_Unlinked")}` - : labels[i], - ); const stationHead = L("B08_Quantity_Summary_Station"); - // 160-2 — 빈 조합 칸 이름 · 같은 조합 열 칸에도 「미지정」(미연결 시설이 있을 때만 열) - const missingHead = L("B08_Quantity_Summary_Unassigned"); - const here = stations.map((station) => unlinkedAt(unlinked, station)); - const missing = here.map((at) => unassignedText(at, L("B08_Quantity_Link_Body"))); - const hasMissing = missing.some(Boolean); - const comboIds = new Set(columns.map((column) => column.id)); - const markOf = (at: readonly UnlinkedFacility[]): Record => - Object.fromEntries( - [...unassignedCombos(at)].filter((id) => comboIds.has(id)).map((id) => [id, missingHead]), - ); + // 198-1 — 미연결 시설 측점 = 측점 칸 왼쪽 노랑 램프(값 빈칸 · 누르면 바로 연결 · 154) · 호버 = 빈 조합 칸(160-2) + const missing = stations.map((station) => + unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body")), + ); const table = createDataTable({ columns: [ - { key: "station", label: stationHead, width: widthOf(stationHead, ...shown) }, - ...(hasMissing - ? [{ key: "unassigned", label: missingHead, width: widthOf(missingHead, ...missing) }] - : []), + { key: "station", label: stationHead, width: widthOf(stationHead, ...labels) + LAMP_W }, ...columns.map((column) => ({ key: column.id, label: column.name, @@ -185,18 +173,27 @@ function buildTable( ], // 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값 rows: stations.map((station, i) => ({ - station: shown[i], - ...(hasMissing ? { unassigned: missing[i] } : {}), - ...markOf(here[i]), + station: labels[i], ...values.get(stationKey(station)), })), total, onRowClick: (i) => onRowClick(stations[i], labels[i]), - frozen: hasMissing ? 2 : 1, + cellClass: (i, key) => (key === "station" && missing[i] ? LAMP_CLASS : null), + frozen: 1, empty: "", caption: L("B08_Quantity_Tab_StructureSummary"), + fillViewport: true, }); table.root.dataset.b08Summary = "table"; + // 줄을 다시 그려도(보이는 줄만) 떠 있는 root 하나에 걸어 둠 — 줄 번호 = 줄 `data-i` + attachHoverInfo( + table.root, + (cell) => { + const why = missing[Number(cell.closest("tr")?.dataset.i)]; + return why && `${L("B08_Quantity_Summary_LampTip")} — ${why}`; + }, + `td.${LAMP_CLASS}`, + ); return table.root; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index b39d8309b..c27d84018 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -352,18 +352,21 @@ export function createMain( async function mount(doc: unknown, at: Selection): Promise { const readOnly = !writable(at); + const mine = seq; // 모듈 · 집계표 번호를 기다리는 사이 다른 화면(옵션 정의 등)이 열렸으면 그만둠 host.replaceChildren(); if (at.kind === "table") { const create = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>( SHEET_MODS, "createSheet", ); + if (mine !== seq) return; if (!create) return empty(L("M02_NoSheet")); // 구조물집계표(프로젝트 층 아님)만 — 도면 · 면적산출 줄에 번호 · 칸을 고르면 아래 컨테이너 · // 고칠 수 있으면 일위대가 · 도면 · 면적산출 칸은 조회 후 선택 const struct = at.layer !== "project" && at.name === STRUCTURE_TABLE ? buildPanel(at.layer) : null; if (struct) await struct.reload(); + if (mine !== seq) return struct?.destroy(); panel = struct; // ⚠ sheet 는 바깥(다시 열기 등으로 drop() 이 null 로 되돌리는) 값 — 표 부품이 // 헐린 뒤에도 안 떼인 이벤트가 이 줄을 부르면 sheet?.getDoc() 가 undefined 라 @@ -417,6 +420,7 @@ export function createMain( // 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로 · // 단가승격 · 사전조합(50-3) = 같은 상세 모드(도면 · 면적 · 자재 없음 · 이름 = 키) const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail"); + if (mine !== seq) return; const mode = isPrecombo(at.kind) ? at.kind : undefined; const labor = mountLaborDetail(host, doc, { layer: at.layer, @@ -447,6 +451,7 @@ export function createMain( DRAWING_MODS, at.kind === "structure" ? "mountStructureSheets" : "mountDrawingTemplate", ); + if (mine !== seq) return; if (mountDrawing) { editor = mountDrawing(host, doc, { onSave: () => void doSave(), diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py index 29b3282e2..d69e9ad4d 100644 --- a/common_util/common_util_cross_berm.py +++ b/common_util/common_util_cross_berm.py @@ -50,6 +50,17 @@ class BermSpec(NamedTuple): interval_m: float = BERM_DEFAULT_INTERVAL_M slope_deg: float = BERM_DEFAULT_SLOPE_DEG depth_m: float = BERM_DEFAULT_DEPTH_M + # 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼ · 측점 patch) — None = `depth_m` + depth_left_m: float | None = None + depth_right_m: float | None = None + + +def side_berm(berm: BermSpec | None, side: str) -> BermSpec | None: + """그 쪽 소단 — 쪽마다 옮긴 깊이가 있으면 그 값을 `depth_m` 으로(194-1 · 0 미만 = 0).""" + if berm is None: + return None + own = berm.depth_left_m if side == "left" else berm.depth_right_m + return berm if own is None else berm._replace(depth_m=max(float(own), 0.0)) def cut_profile_points( diff --git a/common_util/common_util_cross_berm.ts b/common_util/common_util_cross_berm.ts index 8f1f69dc3..eb0ec8f14 100644 --- a/common_util/common_util_cross_berm.ts +++ b/common_util/common_util_cross_berm.ts @@ -39,6 +39,16 @@ export interface BermSpec { intervalM: number; slopeDeg: number; depthM?: number; + /** 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼ · 측점 patch) — 없으면 `depthM`. */ + depthLeftM?: number | null; + depthRightM?: number | null; +} + +/** 짝: `side_berm`. 그 쪽 소단 — 쪽마다 옮긴 깊이가 있으면 그 값을 `depthM` 으로(0 미만 = 0). */ +export function sideBerm(berm: BermSpec | null, side: string): BermSpec | null { + if (berm === null) return null; + const own = side === "left" ? berm.depthLeftM : berm.depthRightM; + return own === undefined || own === null ? berm : { ...berm, depthM: Math.max(own, 0) }; } export function bermSpec( diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index b568edabb..b9b99b1d8 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -143,6 +143,9 @@ export interface CrossDesignResult { /** 노견 확장(m · 139-6) — 넓힌 쪽만. */ shoulder_extend_left_m?: number; shoulder_extend_right_m?: number; + /** 소단 쪽마다 옮긴 깊이(m · 194-1) — 옮긴 쪽만. */ + berm_depth_left_m?: number; + berm_depth_right_m?: number; cross_slope_pct: number; ditch: Record; /** 결과 — **실제로 섰나**. */ @@ -588,6 +591,12 @@ export function computeCrossDesign( slope_deg: round4(options.berm.slopeDeg), depth_m: round4(Math.max(options.berm.depthM ?? BERM_DEFAULT_DEPTH_M, 0)), }; + // 쪽마다 옮긴 깊이(194-1) — 옮긴 쪽만 되싣는다(짝: 파이썬) + const { depthLeftM, depthRightM } = options.berm; + if (depthLeftM !== undefined && depthLeftM !== null) + result.berm_depth_left_m = round4(Math.max(depthLeftM, 0)); + if (depthRightM !== undefined && depthRightM !== null) + result.berm_depth_right_m = round4(Math.max(depthRightM, 0)); } // 노견 확장(139-6) — 넓힌 쪽만 되싣는다(짝: 파이썬) if (geometry.shoulderExtendLeft > 0) diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index ec3d0f978..80630a777 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -11,6 +11,7 @@ import { cutProfilePoints, elevationAt, fillProfilePoints, + sideBerm, } from "./common_util_cross_berm"; /** 절토 사면 경사 구간 한 칸 — 짝 파이썬 `cut_slope_segments` 와 같은 항목. */ @@ -306,7 +307,7 @@ export class SectionGeometry { startDist, startZ, this.fillRatio, - this.berm, + sideBerm(this.berm, side), // 쪽마다 옮긴 깊이(194-1) undefined, edgeZ, ); diff --git a/docs/raw/plans/2026-10-10_plan_194-199.md b/docs/raw/plans/2026-10-10_plan_194-199.md new file mode 100644 index 000000000..1ef26378e --- /dev/null +++ b/docs/raw/plans/2026-10-10_plan_194-199.md @@ -0,0 +1,37 @@ +# 2026-10-10 계획 194~199장 기록 — 소단 위아래 · 보라선 글 · 알림 줄 칩 · 대시보드 카드 · B08 램프 · B08 표 높이 + +근거 = `docs/raw/요구_1010_2/사용자요구_1010_2.md` 46~52 · 메인 합침 d2f76e03b. + +## 194. 소단 = 좌측 위치 칸 없앰 · 횡단에서 위아래로만 옮김 (sub7) +정함(브레인) = 사용자 46번 · 178-2 의 좌측 「소단 위치(도로 아래)」 칸 지움 · 기본 = 노면 끝 아래 1 m · 사용자가 횡단도에서 소단 선을 골라 ▲▼ 로만 옮김(◀▶ 막음 · 성토 사면 무리 이동과 안 부딪힘 · 193 축 제약 꼴) · 옮긴 값 = 측점마다 횡단 설계 patch(정본 · [저장] 때 서버 다시 셈) · 도로보다 높을 수 없음 +- [x] 194-1 칸 지움 · 소단 선 고름 = ▲▼ 만 · 측점별 깊이 patch · 짝 거울 — test_178_2 고침 · 새 시험 + - 검증완료 — 새 test_194_1_berm_depth 14(칸 없음 · 기본 1 m · 쪽마다 깊이 거울 · patch 왕복 · 서버 = 정본 구간 + 측점 깊이 · 구간 지우면 소단 없음 · ▲▼ 만 · 0 · 사면 끝 멈춤) + test_178_2 · 54_26 · 193_1 · 139_6 · 137 · 사용자값 짝 등 합 86 통과 · typecheck 통과 + +## 195. B06 보라 선 고를 때 정보 글 = 길이 · 경사비율 · 수평 · 높이 (sub1) +정함(브레인) = 사용자 47번 · 「사면 길이」 → 「길이」 · 차례 = 길이 → 경사비율 → 수평 → 높이 · 값 · 단위 셈은 그대로 +- [x] 195-1 보라 선 고름 정보 글(선 길이 정보창 · 고름 패널 둘 다 같은 글이면 둘 다) — 새 시험 + - 검증완료 — `pieceLabel` 한 곳(정보창 글만 · 고름 패널 제목은 이동 글이라 무변) · 새 test_195_1 + test_137 · 179_1 · 193_1 · 139_6 합 24 통과 · typecheck 통과 +- [x] 194-2 (48번 보충) 소단 고름 = 횡단도 소단 수평선 강조 + 좌측 패널 · 소단 컨테이너 켬 · 이동 셋 분리(성 · 절토 = X · 도로 = Y · 소단 = 높이 Y) · 방향키로 옮긴 뒤 다시 그려도 같은 선 조각이 고른 채 남음(다른 조각으로 넘어가지 않음 · 조각 키 = 갈래 + 쪽 + 차례) — 새 시험 + - 검증완료 — test_194_1 흉내 DOM: 갈래 축(소단 depth · 사면 x · 노면 y) · 그리는 차례 · 깊이 바꿔 다시 그려도 같은 조각(번호 1 → 6 · 키로 되살림) · 좌측 소단 상자 알림 한 번(켬 40 → 끔) · 합 86 통과 + +## 196. B06 횡단 맨 위 알림 줄 — 군더더기 지움 · 이름 줄임 · 내용 칸 하나 공유 (sub8) +정함(브레인) = 사용자 49번 · 알림 줄(B06_Section_UI_Cross_FillSlope_Notice · 성토사면 5m 초과 · 소단 검토 · 포장 권장 · 열 수 칸)과 그 위 줄(측점 없는 구조물 등) · 항목 = 짧은 이름 + 수(예 「성토 5m↑ 3」 「소단 검토 5」 「포장 권장 2」) · 긴 설명 글은 지우고 공용 호버(185-3 ui_template_hover_info)로 · 펼침은 항목마다 따로가 아니라 줄 아래 내용 칸 하나를 같이 씀(누른 항목 목록만 · 다시 누르면 닫힘 · 한 번에 하나) · 지운 것 목록은 보고에 +- [x] 196-1 알림 줄 칩 꼴 · 내용 칸 하나 · 설명 = 호버 · sticky · 열 수 칸 그대로 — 새 시험 + - 검증완료 — 새 `_Cross_Notice_Chips`(칩 + 내용 칸 하나 · 호버) · 측점 없는 구조물 줄 까닭 글 = 호버 · 새 test_196_1(7) + fill_slope_warn · berm_review · 138 열 수 · 185_3 호버 합 19 통과 · typecheck 통과 + +## 197. 대시보드 「구조물 옵션」 카드 → M02 구조물 옵션 정의로 바로 (sub1) +정함(브레인) = 사용자 50번 · 카드가 M02 open=옵션 으로 감 → M02_UI_Side reveal("옵션") 이 옵션 묶음 첫 단추를 누름(구조물 옵션 정의 줄이 아닐 수 있음 · 추정) · 다른 카드(구조물조합 · 도면)도 같이 확인 +- [x] 197-1 대시보드 카드 → M02 「구조물 옵션 정의」 화면이 바로 열림 · 구조물조합 · 도면 카드도 제자리 — 새 시험 + - 원인 — 해시 open=옵션 이 옵션 줄을 눌러 정의 화면을 띄우는 사이 첫 화면 집계표의 `mount` 가 모듈 · 번호를 기다리다 뒤늦게 끝나 정의 화면 위에 덮음(`mount` 에 낡음 검사 없음) · 카드 → 해시 · reveal 길은 맞았음 + - 고침 — `M02_MasterTemplete_UI_Main.ts` `mount` 기다림 뒤마다 `mine !== seq` 면 그만둠(집계표 · 일위대가 상세 · 도면) + - 검증완료 — 새 test_197_1(4: 카드 셋 해시 · 해시 진입 셋 · reveal 옵션만 누름 · mount 낡음 검사 Node 3경우 · 고침 빼면 실패 확인) + b01_settings_cards · m02_layer_screen · card_shell · structure_def_screen 포함 38 통과 · M02_UI_Main 쓰는 시험 116 중 115 통과(test_m02_card_sheet_edit 1건 = spreadsheet.ts 문구 시험 · 이번 고침 밖) · typecheck 통과 + +## 198. B08 구조물 집계표 — 미연결 = 줄 왼쪽 램프 하나 · 미지정 열 지움 (sub8) +정함(브레인) = 사용자 51번 · 160 · 162 때 더한 「미지정」 열 지움 · 줄 맨 왼쪽에 작은 램프 하나(미연결 = 경고 노랑 · 연결 = 안 보임 또는 초록 · 177-1 · 170-3 상태 색 토큰 재사용) · 램프에 마우스 = 공용 호버로 까닭(어느 조합이 미연결인지) +- [x] 198-1 미지정 열 지움 · 줄 왼쪽 램프 · 호버 까닭 · 엑셀 꼴 · 합계 영향 없음 — test_160 · 162 등 고침 · 새 시험 + - 검증완료 — test_198_1 4건(미연결 줄 측점 칸만 램프 1개 · 호버 「구조물 조합 미연결 — 빈 칸」 · 경고 노랑 토큰 · 줄 값 = 측점 + 서버 값뿐) + B08 시험 12파일 57 통과 · typecheck 0 · 구조물 집계표는 엑셀 내보내기 없음 · 「· 미연결」 측점 글 · 조합 칸 「미지정」 표기도 지움 · 서버 안내 「노랑 램프 줄」 + +## 199. B08 표 높이 = 브라우저 창 아래 끝까지(페이지 스크롤 없음) (sub5) +정함(브레인) = 사용자 52번 · 합계 줄 고정 + 안쪽 스크롤은 그대로 · 표 칸 최대 높이 = 고정값 대신 창 높이에서 표 위쪽 자리를 뺀 만큼(창 크기 바뀌면 다시 셈) · 줄이 적으면 내용만큼 · 공용 표 양식에 옵션 하나(다른 페이지도 쓸 수 있게) · 이번 적용은 B08 표들 +- [x] 199-1 B08 표 칸 = 창 아래 끝까지 · 페이지 스크롤 0 · 합계 줄 고정 유지 · 창 크기 바뀜 대응 — 새 시험 + - 검증완료 — test_199_1 12건(옵션 fillViewport · 최대 높이 = 창 − 위 − 아래 자리 · 창 크기 바뀜 900→600 재셈 · 스크롤 무관 · 아래 요소 늘면 줄어듦 · 합계 줄 sticky 그대로 · B08 표 5곳 적용) + 관련 B08 시험 · test_ui_data_table 통과 · typecheck 0 · test_124 「화면 연결」 1건 실패는 이 변경 전부터(HEAD 에 해당 글 없음) diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index b52e8ccca..a87b28d50 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -3573,6 +3573,7 @@ "weep_hole_area_m2": false }, "berm": { + "depth_m": false, "height_m": false, "length_m": false, "before_m": false, @@ -3807,8 +3808,7 @@ "id": "gbermdetail", "이름": "상세 내용", "옵션": [ - "width_m", - "depth_m" + "width_m" ], "형상": {} } diff --git a/resources/tester/helper_194_1_berm_pick.cjs b/resources/tester/helper_194_1_berm_pick.cjs new file mode 100644 index 000000000..96b25c34a --- /dev/null +++ b/resources/tester/helper_194_1_berm_pick.cjs @@ -0,0 +1,304 @@ +/* 194-1 · 194-2 소단 고름 헬퍼 — `_Cross_Design_Pick` 을 그 자리에서 트랜스파일해 흉내 DOM 으로 돌림. + * 소단 = 높이만(▲▼ · ◀▶ 막힘 · 노면 끝 위 금지) · 조각 키로 다시 그려도 같은 조각 · 좌측 상자 알림. 짝 = `test_194_1_berm_depth.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +class Node { + constructor(tag) { + this.tagName = tag; + this.children = []; + this.attrs = {}; + this.listeners = {}; + this.hidden = false; + this.isConnected = true; + this._text = ""; + this._cls = new Set(); + const self = this; + this.classList = { + add: (...n) => n.forEach((c) => self._cls.add(c)), + toggle: (c, on) => ((on ?? !self._cls.has(c)) ? self._cls.add(c) : self._cls.delete(c)), + contains: (c) => self._cls.has(c), + }; + } + get descendants() { + return this.children.flatMap((c) => [c, ...c.descendants]); + } + /** `.이름` 한 개만 — 패널 공용 부품이 슬롯 단추를 찾는 꼴. */ + querySelector(sel) { + return this.descendants.find((n) => n._cls.has(sel.slice(1))) ?? null; + } + getClientRects() { + return this._cls.has("is-hidden") ? [] : [{}]; + } + set className(v) { + this._cls = new Set(String(v).split(/\s+/).filter(Boolean)); + } + set textContent(v) { + this._text = String(v); + } + get textContent() { + return this._text + this.children.map((c) => c.textContent).join(""); + } + append(...kids) { + this.children.push(...kids); + } + insertBefore(kid) { + this.children.push(kid); + } + setAttribute(n, v) { + this.attrs[n] = String(v); + if (n === "class") this.className = v; + } + getAttribute(n) { + return this.attrs[n] ?? null; + } + hasAttribute(n) { + return n in this.attrs; + } + addEventListener(t, fn) { + (this.listeners[t] ??= []).push(fn); + } + click() { + (this.listeners.click ?? []).forEach((fn) => fn({ stopPropagation: () => undefined })); + } +} +const docListeners = {}; +globalThis.document = { + createElement: (t) => new Node(t), + createElementNS: (_n, t) => new Node(t), + addEventListener: (t, fn) => (docListeners[t] ??= []).push(fn), + removeEventListener: (t, fn) => { + docListeners[t] = (docListeners[t] ?? []).filter((f) => f !== fn); + }, +}; +const key = (name) => + [...(docListeners.keydown ?? [])].forEach((fn) => + fn({ key: name, target: null, preventDefault: () => undefined }), + ); + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const req = (r) => + r.startsWith("@util/") + ? loadTs(path.join(ROOT, "common_util", `${r.slice(6)}.ts`)) + : loadTs(path.join(path.dirname(full), `${r}.ts`)); + new Function("exports", "module", "require", js)(box.exports, box, req); + cache.set(full, box.exports); + return box.exports; +} +globalThis.Element = Node; + +const pick = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Pick.ts")); +const x = (o) => 100 - o * 12; // +offset = 화면 왼쪽 +const y = (e) => 900 - (e - 100) * 12; +const line = (pts, structure = false) => { + const el = new Node("polyline"); + el.setAttribute("class", "b06-chart__design-cross"); + el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" ")); + if (!structure) el.setAttribute(pick.DESIGN_LINE_ATTR, ""); + return el; +}; +/** 방향키 한 번 — 막혔나(preventDefault) 돌려줌. */ +const press = (name) => { + let prevented = false; + [...(docListeners.keydown ?? [])].forEach((fn) => + fn({ key: name, target: null, preventDefault: () => (prevented = true) }), + ); + return prevented; +}; + +/** 양성토 단면(노면 끝 표고 100) — 좌 소단 깊이 `dl`(노면 끝 아래) · 우 1.0 · 사면 1:1.5 · 끝 표고 97. `order` = 그리는 차례. */ +function card({ dl = 1, stored = undefined, order = ["left", "road", "right"], stationId } = {}) { + for (const k of Object.keys(docListeners)) if (k === "keydown") docListeners[k] = []; + const design = { + road_edges: { + left: { offset_m: 2, elevation_m: 100 }, + right: { offset_m: -2, elevation_m: 100 }, + }, + ditch: null, + ditch_side: null, + ditch_enabled: false, + section_mode: "both_fill", + berm: { width_m: 0.5, interval_m: 3, slope_deg: 0, depth_m: 1 }, + }; + if (stored !== undefined) design.berm_depth_left_m = stored; + const slope = (s, d) => { + const k = s === "left" ? 1 : -1; + const knee = 2 + 1.5 * d; + const toe = knee + 0.5 + 1.5 * (100 - d - 97); + return [ + [k * toe, 97], + [k * (knee + 0.5), 100 - d], + [k * knee, 100 - d], + [k * 2, 100], + ]; + }; + const lines = { + left: line(slope("left", dl)), + road: line([ + [2, 100], + [0, 100.04], // 노면 횡단경사 2 % — 깊이 0 소단이 노면과 한 직선으로 안 합쳐짐 + [-2, 100], + ]), + right: line(slope("right", 1)), + }; + const layer = new Node("g"); + layer.append(...order.map((name) => lines[name])); + const values = { left: 0, right: 0 }; + const sets = []; + const focus = []; + const handle = pick.attachDesignLinePick({ + layer, + design, + x, + toDisplayY: y, + onPick: () => undefined, + shift: { valueOf: (s) => values[s], set: (s, v) => (values[s] = v) }, + berm: { set: (s, v) => sets.push([s, v]), focus: (on) => focus.push(on) }, + stationId, + }); + const find = (test) => handle.pieces.findIndex(test); + const mid = (p) => (p.from.offset + p.to.offset) / 2; + const at = { + leftBerm: find((p) => pick.isBermPiece(design, p) && mid(p) > 0), + rightBerm: find((p) => pick.isBermPiece(design, p) && mid(p) < 0), + leftUpper: find((p) => !pick.isBermPiece(design, p) && mid(p) > 2 && mid(p) < 2 + 1.5 * dl), + road: find((p) => mid(p) > 0 && mid(p) < 2), + }; + const slot = (name) => + handle.panel.descendants.find( + (n) => n._cls.has(`b06-structure-panel__btn--${name}`) && !n.parentHidden, + ); + const rows = () => + handle.panel.descendants + .filter((n) => n._cls.has("b06-structure-panel__struct")) + .map((n) => n._cls.has("is-hidden")); + const text = (cls) => handle.panel.descendants.find((n) => n._cls.has(cls)).textContent; + return { design, handle, values, sets, focus, at, slot, rows, text }; +} + +const out = {}; + +// ① 갈래 · 축 — 소단 = depth · 사면 = x · 노면 = y · 키 +{ + const c = card(); + out.axis = Object.fromEntries( + Object.entries(c.at).map(([name, i]) => [ + name, + pick.pieceMoveAxis(c.design, c.handle.pieces[i]), + ]), + ); + out.keys = pick.pieceKeys(c.design, c.handle.pieces).sort(); +} + +// ② 소단 고름 — ▲▼ 만 · ◀▶ 막힘 · ↺ = 구간 기본 · 좌측 상자 켬 알림 +{ + const c = card(); + c.handle.setActive(c.at.leftBerm); + const [moveHidden, liftHidden] = c.rows(); + const leftBlocked = press("ArrowLeft"); + const rightBlocked = press("ArrowRight"); + const afterSide = { values: { ...c.values }, sets: c.sets.length }; + const upTaken = press("ArrowUp"); + press("ArrowDown"); + c.handle.panel.descendants + .filter((n) => n._cls.has("b06-structure-panel__btn--reset")) + .at(-1) + .click(); + out.berm = { + title: c.text("b06-structure-panel__title"), + readout: c.text("b06-structure-panel__value"), + moveHidden, + liftHidden, + leftBlocked, + rightBlocked, + afterSide, + upTaken, + sets: c.sets, + focus: c.focus, + }; + c.handle.setActive(c.at.leftUpper); // 사면으로 바꿈 — 소단 상자 끔 · ▲▼ 막힘 · 깊이 안 바뀜 + const before = c.sets.length; + out.berm.slopeUpBlocked = press("ArrowUp"); + out.berm.slopeSets = c.sets.length - before; + out.berm.slopeTitle = c.text("b06-structure-panel__title"); + out.berm.focusAfter = c.focus.at(-1); + // 노면 = ▲▼ 는 종단도로 넘김(안 막음) + c.handle.setActive(c.at.road); + out.berm.roadUpPassed = !press("ArrowUp"); + out.berm.roadSets = c.sets.length - before; +} + +// ③ 도로 위 금지 = 0 에서 멈춤 · 사면 끝 앞에서 멈춤 +{ + const top = card({ dl: 0, stored: 0 }); + top.handle.setActive(top.at.leftBerm); + press("ArrowUp"); + const deep = card({ dl: 2.9, stored: 2.9 }); + deep.handle.setActive(deep.at.leftBerm); + press("ArrowDown"); // 2.9 → 3.0 = 사면 끝(97) — 막힘 + press("ArrowUp"); + out.limit = { topSets: top.sets, topFound: top.at.leftBerm >= 0, deepSets: deep.sets }; +} + +// ④ 옮긴 뒤 다시 그려도 같은 조각(194-2) — 그리는 차례 · 깊이가 바뀌어도 +{ + const first = card({ stationId: "S1" }); + pick.setPickedLine("S1", first.at.leftBerm); + const again = card({ + dl: 1.1, + stored: 1.1, + order: ["right", "road", "left"], + stationId: "S1", + }); + const restored = pick.pickedLineAt("S1"); + pick.setPickedLine("S1", again.at.leftUpper); + const third = card({ dl: 1.2, stored: 1.2, stationId: "S1" }); + out.stable = { + firstIndex: first.at.leftBerm, + againIndex: again.at.leftBerm, + restored, + upperRestored: pick.pickedLineAt("S1") === third.at.leftUpper, + upperIndexMoved: again.at.leftUpper !== third.at.leftUpper, + }; + pick.setPickedLine("S1", null); +} + +// ⑤ 카드 창구 — 소단 깊이 변경 · 좌측 상자 알림(한 번만 · 이 측점이 켠 때만 끔) +{ + const changes = []; + const design = { ground_type: "soil", section_mode: "both_fill", shoulder_extend_left_m: 0.3 }; + const moves = pick.designLineMoves({ station_id: "S9", chainage_m: 40, design }, (ch, change) => + changes.push([ch, change]), + ); + moves.berm.set("left", 1.3); + const host = []; + pick.setBermPickHost((ch) => host.push(ch)); + moves.berm.focus(true); + moves.berm.focus(true); // 다시 그린 카드 — 안 부름 + pick + .designLineMoves({ station_id: "S8", chainage_m: 20, design }, () => undefined) + .berm.focus(false); + moves.berm.focus(false); + out.moves = { + stationId: moves.stationId, + shiftValue: moves.shift.valueOf("left"), + change: changes[0], + host, + none: pick.designLineMoves({ station_id: "S7", chainage_m: 0, design }), + }; + pick.setBermPickHost(null); +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_ui_fill_viewport.cjs b/resources/tester/helper_ui_fill_viewport.cjs new file mode 100644 index 000000000..258c2999f --- /dev/null +++ b/resources/tester/helper_ui_fill_viewport.cjs @@ -0,0 +1,135 @@ +/* 창 아래 끝까지 칸(`ui_template/ui_template_data_table.ts` · PLAN 199-1) 단위검증 헬퍼 — + * 트랜스파일해 가짜 DOM(요소 트리 · 창 · ResizeObserver)에서 돌리고 판정할 값을 JSON 으로 냄. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "ui_template", "ui_template_data_table.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; + +// ── 가짜 DOM ── +const listeners = { resize: new Set() }; +const observers = []; +const win = { + innerHeight: 900, + scrollY: 0, + addEventListener: (t, f) => listeners[t]?.add(f), + removeEventListener: (t, f) => listeners[t]?.delete(f), +}; +class RO { + constructor(fn) { + this.fn = fn; + this.targets = new Set(); + observers.push(this); + } + observe(el) { + this.targets.add(el); + } + disconnect() { + this.targets.clear(); + } +} +function el(name, css = {}, height = 0) { + const node = { + name, + css, + height, + top: 0, + parentElement: null, + nextElementSibling: null, + isConnected: false, + style: {}, + getBoundingClientRect() { + return { top: this.top - win.scrollY, height: this.height }; + }, + }; + return node; +} +function link(parent, ...kids) { + kids.forEach((k, i) => { + k.parentElement = parent; + k.nextElementSibling = kids[i + 1] ?? null; + }); + parent.kids = kids; + return parent; +} +const sty = (n) => ({ + rowGap: n.css.rowGap ?? "normal", + paddingBottom: n.css.paddingBottom ?? "0px", + borderBottomWidth: n.css.borderBottomWidth ?? "0px", + marginBottom: n.css.marginBottom ?? "0px", +}); +const doc = { body: null }; +const box = { exports: {} }; +const sandbox = { + window: win, + document: doc, + getComputedStyle: sty, + ResizeObserver: RO, +}; +new Function("exports", "module", "require", ...Object.keys(sandbox), js)( + box.exports, + box, + (p) => (p.endsWith(".css") ? {} : require(p)), + ...Object.values(sandbox), +); +const m = box.exports; + +// 문서: html > body(padding-bottom 8) > page(padding-bottom 16 · gap 8) > [tabs 40] [card(padding-bottom 12) > [root][note 40]] +const html = el("html"); +const body = el("body", { paddingBottom: "8px" }); +const page = el("page", { paddingBottom: "16px", rowGap: "8px" }); +const tabs = el("tabs", {}, 40); +const card = el("card", { paddingBottom: "12px", marginBottom: "4px" }); +const root = el("root"); +const note = el("note", {}, 40); +link(html, body); +link(body, page); +link(page, tabs, card); +link(card, root, note); +doc.body = body; +root.top = 200; +const out = { clamp: m.fillHeight(900, 200, 68), small: m.fillHeight(300, 250, 100) }; + +// 아래 자리 = note(40 + 줄 사이 0) + card 여백(12) + card 바깥 여백(4) + page 아래(16) + body 아래(8) +out.below = m.spaceBelow(root); +out.belowExpected = 40 + 0 + 12 + 4 + 16 + 8; + +// 아직 문서에 안 붙음 — 높이 안 건드림 +let fits = 0; +const stop = m.fillViewport(root, () => (fits += 1)); +out.beforeAttach = root.style.maxHeight ?? null; +out.observed = + observers.some((o) => o.targets.has(root)) && observers.some((o) => o.targets.has(body)); + +// 붙음 → 관찰자가 부름 +[html, body, page, tabs, card, root, note].forEach((n) => (n.isConnected = true)); +observers.forEach((o) => o.fn()); +out.first = root.style.maxHeight; +out.fits = fits; + +// 창 크기 바뀜 → 다시 셈 +win.innerHeight = 600; +[...listeners.resize].forEach((f) => f()); +out.afterResize = root.style.maxHeight; + +// 스크롤해도 같은 값(문서 좌표 기준) +win.scrollY = 80; +[...listeners.resize].forEach((f) => f()); +out.afterScroll = root.style.maxHeight; +win.scrollY = 0; + +// 아래 형제가 늘어남 → 그만큼 줄어듦 +note.height = 100; +observers.forEach((o) => o.fn()); +out.afterNoteGrow = root.style.maxHeight; + +// 칸이 떨어지면 멈춤 +root.isConnected = false; +[...listeners.resize].forEach((f) => f()); +out.listenersAfterDetach = listeners.resize.size; +stop(); + +console.log(JSON.stringify(out)); diff --git a/resources/tester/test_137_design_pick.py b/resources/tester/test_137_design_pick.py index 0e17f6d7e..af7412b9a 100644 --- a/resources/tester/test_137_design_pick.py +++ b/resources/tester/test_137_design_pick.py @@ -57,9 +57,9 @@ def test_치수_사면길이_수평_높이_구배(out) -> None: assert abs(fill["lengthM"] - 13**0.5) < 1e-9 assert out["measure"]["road"]["grade"] == "2.0%" # 노면 = % assert out["measure"]["flatSlope"] == "0.0%" - # 두 줄(151-1) — 1줄 = 길이 · 수평 · 2줄 = 높이 · 구배 - assert out["label"]["fill"] == "사면길이 3.61m · 수평 3.00m\n높이 2.00m · 1:1.50" - assert out["label"]["road"] == "길이 2.50m · 수평 2.50m\n높이 0.05m · 2.0%" + # 두 줄(151-1 · 195-1) — 1줄 = 길이 · 경사비율 · 2줄 = 수평 · 높이 + assert out["label"]["fill"] == "길이 3.61m · 경사비율 1:1.50\n수평 3.00m · 높이 2.00m" + assert out["label"]["road"] == "길이 2.50m · 경사비율 2.0%\n수평 2.50m · 높이 0.05m" def test_구역_도로_절토_성토(out) -> None: @@ -84,7 +84,7 @@ def test_실제_배관_측점_조각과_판정_선(out) -> None: assert real["hitsBelowStructure"] is True # 구조물 판정면이 위 assert real["picked"] == [0] and real["stopped"] is True # 카드 고름으로 안 번짐 assert real["activeCount"] == 1 and real["activeIndex"] == 1 - assert real["labelOn"]["hidden"] is False and real["labelOn"]["text"].startswith("사면길이 ") + assert real["labelOn"]["hidden"] is False and real["labelOn"]["text"].startswith("길이 ") assert real["labelOff"] == {"hidden": True, "text": ""} assert set(real["zones"]) <= {"road", "cut", "fill"} diff --git a/resources/tester/test_143_1_b08_linked_snapshots.py b/resources/tester/test_143_1_b08_linked_snapshots.py index 2f48bbcbf..6eb698a8f 100644 --- a/resources/tester/test_143_1_b08_linked_snapshots.py +++ b/resources/tester/test_143_1_b08_linked_snapshots.py @@ -154,7 +154,7 @@ def test_사본_없는_시설_알림_응답에_사본(root): out = _run(root) # 100(유출 흙막이) · 200 · 300 · 400 · 500(기본 날개벽 잇기) · 600 · 개거 700 = 7곳 assert not any("wing" in c["id"] for c in out["columns"]) - assert "조합 사본 없는 시설 7곳 — 「미연결」 줄을 눌러 조합 연결" in out["notes"] + assert "조합 사본 없는 시설 7곳 — 노랑 램프 줄을 눌러 조합 연결" in out["notes"] assert not any("관 연장 없음" in n for n in out["notes"]) assert set(out["linked"]) == {"pipe@100.0", "pipe@500.0__gford"} @@ -187,7 +187,7 @@ def test_화면_조합_이름_구조물도는_사본에서(): ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") assert "loadPickerRows" not in ui # 층 목록 GET 안 씀 assert "linkedOnly(" in ui - assert "unlinkedAt(" in ui and "B08_Quantity_Summary_Unlinked" in ui # 154-1 미연결 줄 + assert "unlinkedAt(" in ui and "B08_Quantity_Summary_LampTip" in ui # 154-1 · 198-1 미연결 램프 drawing = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.ts").read_text(encoding="utf-8") assert "readTemplate" not in drawing # 층 폴백 없음 assert "B08_Quantity_Sheets_NoCopy" in drawing diff --git a/resources/tester/test_160_2_b08_unassigned.py b/resources/tester/test_160_2_b08_unassigned.py index 7b9a37433..e2e351912 100644 --- a/resources/tester/test_160_2_b08_unassigned.py +++ b/resources/tester/test_160_2_b08_unassigned.py @@ -1,4 +1,4 @@ -"""160-2 B08 구조물 집계표 「미지정」 — 측점 옆 열 글 · 같은 조합 열 칸 표기(화면만). +"""160-2 B08 구조물 집계표 빈 조합 칸 글(198-1 부터 미연결 램프 호버 글 · 「미지정」 열 · 칸 표기는 지움). `B08_Quantity_Summary_Data.ts` 를 `common_util_summary_item.ts` 와 이어 붙여 node 로 돌림(61-1 시험과 같은 꼴). """ @@ -47,31 +47,23 @@ FACILITIES = [ ] -def test_미지정_글과_조합_칸(): +def test_빈_조합_칸_글(): out = _run( f"\nconst f = {json.dumps(FACILITIES, ensure_ascii=False)};" "\nconst at = (s) => unlinkedAt(f, s);" "\nconsole.log(JSON.stringify({" " t100: unassignedText(at(100), '본체'), t160: unassignedText(at(160), '본체')," - " t0: unassignedText(at(0), '본체')," - " c100: [...unassignedCombos(at(100))], c160: [...unassignedCombos(at(160))] }));" + " t0: unassignedText(at(0), '본체') }));" ) assert out["t100"] == "배관: 본체 · 관 / 세월교: 유출 구조" assert out["t160"] == "BOX" and out["t0"] == "" - assert out["c100"] == ["system||SS2"] # 연결된 칸 · 고른 조합 없는 칸은 뺌 - assert out["c160"] == [] def test_화면_연결(): summary = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") assert ( - 'unassignedText(at, L("B08_Quantity_Link_Body"))' in summary + 'unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body"))' in summary ) # 칸 이름 = 모달과 같은 규칙 - assert ( - 'key: "unassigned", label: missingHead' in summary - and "frozen: hasMissing ? 2 : 1" in summary - ) - assert "...markOf(here[i])," in summary and "filter((id) => comboIds.has(id))" in summary - assert '${L("B08_Quantity_Summary_Unlinked")}' in summary # 「· 미연결」 측점 표기 그대로 + assert 'key: "unassigned"' not in summary and "unassignedCombos" not in summary # 198-1 locale = (ROOT / "ui_template/ui_template_locale_b4.ts").read_text(encoding="utf-8") - assert 'B08_Quantity_Summary_Unassigned: ["미지정", "Unassigned"]' in locale + assert "B08_Quantity_Summary_Unassigned" not in locale diff --git a/resources/tester/test_178_2_berm_fill_mirror.py b/resources/tester/test_178_2_berm_fill_mirror.py index 90cc7231d..1de2e3061 100644 --- a/resources/tester/test_178_2_berm_fill_mirror.py +++ b/resources/tester/test_178_2_berm_fill_mirror.py @@ -199,7 +199,9 @@ def test_저장분_깊이_읽기(): def test_좌측_패널_칸_구간_깊이_나름(): - """구조물 옵션 depth_m → 세션 구간 → 측점 제원 · 서버 payload(같은 꼴).""" + """옛 구조물 옵션 depth_m → 세션 구간 → 측점 제원(기본값) · 서버 payload(같은 꼴). + + 좌측 칸은 194-1 에서 숨김 — 옮기기는 횡단도 ▲▼(`test_194_1_berm_depth`).""" source = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") assert "options.depth_m" in source and "Math.max(rawDepth, 0)" in source refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") diff --git a/resources/tester/test_194_1_berm_depth.py b/resources/tester/test_194_1_berm_depth.py new file mode 100644 index 000000000..b524a947c --- /dev/null +++ b/resources/tester/test_194_1_berm_depth.py @@ -0,0 +1,339 @@ +"""194-1 · 194-2 소단 = 좌측 위치 칸 없앰 · 횡단도에서 고른 소단을 ▲▼ 로만 옮김. + +측점 · 쪽마다 깊이(`berm_depth_{side}_m`). + +- 좌측 칸 없음(정의 그룹 · 표시) · 기본 = 노면 끝 아래 1 m +- 짝 거울: `common_util_cross_berm` · 설계 기하(파이썬 ↔ TS) — 쪽마다 깊이(`berm_depth_{side}_m`) +- 서버 [저장] 다시 셈: 구조물 정본 소단 구간 + 측점 patch 깊이(옛 구간 depth_m = 기본값) +- 화면(흉내 DOM · `helper_194_1_berm_pick.cjs`): ▲▼ 만 · ◀▶ 막힘 · 도로 위 금지 · + 옮긴 뒤 같은 조각 · + 좌측 소단 상자 알림 +""" + +import json +import re +import subprocess +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from B06_Section.B06_Section_Engine_Design import compute_cross_design +from B06_Section.B06_Section_Router_Design import ( + USER_TOUCHED_KEYS, + berm_from, + enforce_design_lines, + stored_berm, +) +from B06_Section.B06_Section_Schema import CrossSectionPatch +from common_util.common_util_cross_berm import BermSpec, side_berm +from config.config_system import STANDARD_CROSS_SECTION + +ROOT = Path(__file__).resolve().parents[2] +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +HELPER = Path(__file__).parent / "helper_194_1_berm_pick.cjs" +DEF = ROOT / "resources" / "master_template" / "구조물정의.json" + +node_only = pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") + + +def _ground() -> list[dict]: + """양성토 — 지반이 노면 끝보다 3 m 이상 낮음.""" + return [ + {"offset_m": o / 2, "elevation_m": round(96.0 + 0.02 * o / 2, 4), "valid": True} + for o in range(-40, 41) + ] + + +def _berm(left=None, right=None, depth=1.0) -> BermSpec: + return BermSpec(0.5, 3.0, 0.0, depth, left, right) + + +CASES = [ + {"name": "양쪽 기본 1.0", "berm": _berm()}, + {"name": "좌 2.0 · 우 기본", "berm": _berm(left=2.0)}, + {"name": "좌 0(노면 끝) · 우 0.5", "berm": _berm(left=0.0, right=0.5)}, + {"name": "좌 음수 = 0", "berm": _berm(left=-1.0)}, +] + + +def _python(berm: BermSpec) -> dict: + return compute_cross_design( + _ground(), 100.0, standard=STANDARD_CROSS_SECTION, ground_type="soil", + section_mode="both_fill", berm=berm, + ) # fmt: skip + + +def _flat_z(design: dict, side: str) -> list[float]: + """그 쪽 노면 끝 밖 평탄 도막(소단) 표고 — 지반(≤ 96.4)에 닿기 전 사면만.""" + edge = design["road_edges"][side]["offset_m"] + sign = 1 if side == "left" else -1 + line = sorted( + ( + p + for p in design["design_line"] + if sign * p["offset_m"] >= sign * edge - 1e-6 and p["elevation_m"] > 96.5 + ), + key=lambda p: sign * p["offset_m"], + ) + flats = { + round(a["elevation_m"], 3) # 설계선 표본이 한 단을 여러 도막으로 끊어도 한 표고 + for a, b in zip(line, line[1:], strict=False) + if abs(b["offset_m"] - a["offset_m"]) > 1e-6 + and abs(b["elevation_m"] - a["elevation_m"]) / abs(b["offset_m"] - a["offset_m"]) < 0.05 + } + return sorted(flats) + + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { computeCrossDesign } from "./common_util_cross_design.js"; +const input = JSON.parse(readFileSync(process.argv[2], "utf8")); +const designs = input.cases.map((c) => computeCrossDesign(input.samples, 100, { + standard: input.standard, groundType: "soil", sectionMode: "both_fill", + berm: { widthM: c[0], intervalM: c[1], slopeDeg: c[2], depthM: c[3], + depthLeftM: c[4], depthRightM: c[5] }, +})); +writeFileSync(process.argv[3], JSON.stringify(designs)); +""" + + +@pytest.fixture(scope="module") +def ts_designs(tmp_path_factory) -> list[dict]: + tmp = tmp_path_factory.mktemp("berm194") + out = tmp / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), str(ROOT / "common_util" / "common_util_cross_design.ts"), + "--outDir", str(out), "--module", "esnext", "--target", "es2022", + "--moduleResolution", "bundler", "--ignoreConfig"], + cwd=str(ROOT), check=True, capture_output=True, + ) # fmt: skip + for emitted in out.glob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + src, dst = tmp / "in.json", tmp / "out.json" + src.write_text( + json.dumps( + { + "cases": [list(case["berm"]) for case in CASES], + "samples": _ground(), + "standard": STANDARD_CROSS_SECTION, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(src), str(dst)], check=True, capture_output=True + ) + return json.loads(dst.read_text(encoding="utf-8")) + + +def _close(left, right, path=""): + if isinstance(left, dict): + assert set(left) == set(right), f"{path} 키 다름 {set(left) ^ set(right)}" + for key in left: + _close(left[key], right[key], f"{path}.{key}") + elif isinstance(left, list): + assert len(left) == len(right), f"{path} 길이" + for i, (a, b) in enumerate(zip(left, right, strict=True)): + _close(a, b, f"{path}[{i}]") + elif isinstance(left, (int, float)) and not isinstance(left, bool): + assert abs(float(left) - float(right)) <= 1e-9, f"{path}: {left} vs {right}" + else: + assert left == right, f"{path}: {left!r} vs {right!r}" + + +# ── 좌측 칸 없음 · 기본 1 m ──────────────────────────────────────────────── +def test_좌측_패널_깊이_칸_없음() -> None: + data = json.loads(DEF.read_text(encoding="utf-8")) + assert data["그룹"]["berm"][0]["옵션"] == ["width_m"] + assert data["표시"]["berm"]["depth_m"] is False + berm = next(t for t in data["types"] if t["type_id"] == "berm") + depth = next(o for o in berm["options"] if o["key"] == "depth_m") + assert depth["default"] == 1.0 # 옛 구간 값 · 새 구간 기본 = 노면 끝 아래 1 m + + +def test_기본_1m_수평_한_단() -> None: + design = _python(_berm()) + for side in ("left", "right"): + edge_z = design["road_edges"][side]["elevation_m"] + assert _flat_z(design, side) == [pytest.approx(edge_z - 1.0, abs=1e-3)] + assert "berm_depth_left_m" not in design and "berm_depth_right_m" not in design + + +# ── 짝 거울 · 쪽마다 깊이 ─────────────────────────────────────────────────── +def test_쪽마다_깊이_그_쪽만() -> None: + by = {case["name"]: _python(case["berm"]) for case in CASES} + two = by["좌 2.0 · 우 기본"] + assert _flat_z(two, "left") == [pytest.approx(two["road_edges"]["left"]["elevation_m"] - 2.0)] + assert _flat_z(two, "right") == [pytest.approx(two["road_edges"]["right"]["elevation_m"] - 1.0)] + assert two["berm_depth_left_m"] == 2.0 and "berm_depth_right_m" not in two + assert two["berm"]["depth_m"] == 1.0 # 구간 값은 그대로 + top = by["좌 0(노면 끝) · 우 0.5"] + assert _flat_z(top, "left") == [pytest.approx(top["road_edges"]["left"]["elevation_m"])] + neg = by["좌 음수 = 0"] + assert neg["berm_depth_left_m"] == 0.0 # 도로보다 높을 수 없음 + assert neg["design_line"] == _python(_berm(left=0.0))["design_line"] + assert side_berm(_berm(right=1.5), "right").depth_m == 1.5 + assert side_berm(_berm(right=1.5), "left").depth_m == 1.0 + + +@node_only +def test_파이썬_TS_거울(ts_designs: list[dict]) -> None: + for case, ts in zip(CASES, ts_designs, strict=True): + _close(_python(case["berm"]), ts, case["name"]) + + +def test_짝_머리_표시() -> None: + for name in ("common_util_cross_berm.py", "common_util_cross_berm.ts"): + text = (ROOT / "common_util" / name).read_text(encoding="utf-8") + assert "짝" in text and ("side_berm" in text or "sideBerm" in text) + + +# ── 측점 patch 저장 왕복 · 서버 다시 셈 ───────────────────────────────────── +def test_patch_칸_왕복() -> None: + patch = CrossSectionPatch(chainage_m=40, berm_depth_left_m=1.5, berm_depth_right_m=0) + dumped = {k: v for k, v in patch.model_dump().items() if v is not None} + assert dumped == {"chainage_m": 40, "berm_depth_left_m": 1.5, "berm_depth_right_m": 0} + with pytest.raises(ValidationError): + CrossSectionPatch(chainage_m=40, berm_depth_left_m=-0.1) # 도로 위 금지 + assert {"berm_depth_left_m", "berm_depth_right_m"} <= set(USER_TOUCHED_KEYS) + refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") + assert '"berm_depth_left_m",' in refresh and '"berm_depth_right_m",' in refresh + persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") + assert "patch.berm_depth_left_m = sides.left" in persist + + +def test_저장분_읽기_옛_구간값_기본() -> None: + old = stored_berm({"berm": {"width_m": 0.5, "depth_m": 2.5}}) + assert (old.depth_m, old.depth_left_m, old.depth_right_m) == (2.5, None, None) + moved = stored_berm({"berm": {"width_m": 0.5, "depth_m": 2.5}, "berm_depth_left_m": 1.2}) + assert (moved.depth_left_m, moved.depth_right_m) == (1.2, None) + # 프리뷰 payload(구간 제원 + 쪽 깊이) + spec = berm_from({"width_m": 0.5, "depth_m": 1.0, "berm_depth_right_m": -3}, { + "berm_depth_right_m": -3 + }) # fmt: skip + assert spec.depth_right_m == 0.0 + + +LONG = { + "design_profiles": [ + { + "samples": [ + {"chainage_m": 0, "elevation_m": 100}, + {"chainage_m": 200, "elevation_m": 100}, + ] + } + ] +} + + +def _project(tmp_path: Path, structures: list[dict]) -> Path: + route = tmp_path / "B05_Profile" / "route" + route.mkdir(parents=True) + (route / "structures.json").write_text( + json.dumps({"revision": 1, "structures": structures}, ensure_ascii=False), + encoding="utf-8", + ) + return tmp_path + + +BERM_SPAN = { + "type_id": "berm", + "placement": "interval", + "start_m": 0, + "end_m": 100, + "options": {"width_m": 0.5, "depth_m": 2.5}, # 옛 좌측 칸 값 = 기본값 +} + + +def test_저장_때_서버가_정본_구간_측점_깊이로_다시_셈(tmp_path: Path) -> None: + root = _project(tmp_path, [BERM_SPAN]) + browser = compute_cross_design(_ground(), 100.0, ground_type="soil", section_mode="both_fill") + # 브라우저 patch = 사용자 값(좌 깊이)만 · 설계선은 소단 없는 옛 것 + stored = {**browser, "berm_depth_left_m": 1.5} + sections = [{"chainage_m": 40.0, "samples": _ground(), "design": stored}] + assert enforce_design_lines(LONG, sections, root) == 1 + design = sections[0]["design"] + assert design["berm"]["depth_m"] == 2.5 and design["berm_depth_left_m"] == 1.5 + left_z = design["road_edges"]["left"]["elevation_m"] + right_z = design["road_edges"]["right"]["elevation_m"] + assert _flat_z(design, "left") == [pytest.approx(left_z - 1.5, abs=1e-3)] + assert _flat_z(design, "right") == [pytest.approx(right_z - 2.5, abs=1e-3)] + # 구간 밖 측점 = 소단 없음 · 옮긴 값만 남음(다시 놓으면 그대로 씀) + outside = [{"chainage_m": 150.0, "samples": _ground(), "design": dict(stored)}] + enforce_design_lines(LONG, outside, root) + assert "berm" not in outside[0]["design"] and _flat_z(outside[0]["design"], "left") == [] + + +def test_구간_지우면_저장분_소단도_사라짐(tmp_path: Path) -> None: + root = _project(tmp_path, []) + old = _python(_berm(left=1.5)) + sections = [{"chainage_m": 40.0, "samples": _ground(), "design": old}] + enforce_design_lines(LONG, sections, root) + assert "berm" not in sections[0]["design"] + assert _flat_z(sections[0]["design"], "left") == [] + + +# ── 화면 고름(흉내 DOM) ───────────────────────────────────────────────────── +@pytest.fixture(scope="module") +def ui() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +@node_only +def test_이동_셋_분리(ui: dict) -> None: + assert ui["axis"] == {"leftBerm": "depth", "rightBerm": "depth", "leftUpper": "x", "road": "y"} + assert "berm:left:0" in ui["keys"] and "fill:left:1" in ui["keys"] + + +@node_only +def test_소단_고름_위아래만(ui: dict) -> None: + berm = ui["berm"] + assert berm["title"] == "소단 높이 (좌)" and berm["readout"] == "도로 아래 1.0m" + assert berm["moveHidden"] is True and berm["liftHidden"] is False + assert berm["leftBlocked"] and berm["rightBlocked"] # ◀▶ 막힘 + assert berm["afterSide"] == {"values": {"left": 0, "right": 0}, "sets": 0} # 가로 안 감 + assert berm["upTaken"] + assert berm["sets"] == [["left", 0.9], ["left", 1.1], ["left", 1]] # ▲ · ▼ · ↺ 기본 + assert berm["slopeTitle"] == "사면 이동 (좌)" and berm["slopeUpBlocked"] + assert berm["slopeSets"] == 0 and berm["roadSets"] == 0 + assert berm["roadUpPassed"] # 노면 ▲▼ = 종단도 몫 + + +@node_only +def test_도로_위_금지_사면_끝_앞_멈춤(ui: dict) -> None: + assert ui["limit"]["topFound"] and ui["limit"]["topSets"] == [] # 깊이 0 에서 ▲ = 안 감 + assert ui["limit"]["deepSets"] == [["left", 2.8]] # ▼ 막힘 · ▲ 는 감 + + +@node_only +def test_옮긴_뒤_같은_조각(ui: dict) -> None: + stable = ui["stable"] + assert stable["firstIndex"] != stable["againIndex"] # 번호는 바뀜 + assert stable["restored"] == stable["againIndex"] # 키로 같은 소단 + assert stable["upperRestored"] and stable["upperIndexMoved"] + + +@node_only +def test_좌측_소단_상자_알림_카드_창구(ui: dict) -> None: + assert ui["berm"]["focus"][0] is True and ui["berm"]["focusAfter"] is False + moves = ui["moves"] + assert moves["stationId"] == "S9" and moves["shiftValue"] == 0.3 + chainage, change = moves["change"] + assert chainage == 40 and change["berm_depth"] == {"side": "left", "valueM": 1.3} + assert change["ground_type"] == "soil" # 카드 버튼 값은 그대로 실음 + assert moves["host"] == [40, None] # 한 번만 켬 · 다른 측점 끔은 무시 + assert moves["none"] == {"stationId": "S7"} # 창구 없으면 이동 패널 없음 + selection = (ROOT / "B06_Section" / "B06_Section_UI_Page_Structures_Selection.ts").read_text( + encoding="utf-8" + ) + assert "setBermPickHost(" in selection and 'classList.add("is-active")' in selection diff --git a/resources/tester/test_195_1_pick_label.py b/resources/tester/test_195_1_pick_label.py new file mode 100644 index 000000000..428c06d64 --- /dev/null +++ b/resources/tester/test_195_1_pick_label.py @@ -0,0 +1,19 @@ +"""PLAN 195-1 — B06 보라 선 고름 정보 글: 「사면 길이」 → 「길이」 · 차례 = 길이 → 경사비율 → 수평 → 높이. + +짝: `B06_Section/B06_Section_UI_Cross_Design_Pick.ts` `pieceLabel`. 실제 TS 를 `helper_137_design_pick.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import re + +from test_137_design_pick import out # noqa: F401 — 같은 Node 도우미 결과를 씀 + + +def test_글_차례_넷_길이_경사비율_수평_높이(out) -> None: # noqa: F811 + for key in ("fill", "road"): + label = out["label"][key] + assert "사면" not in label + names = re.findall(r"(길이|경사비율|수평|높이)", label) + assert names == ["길이", "경사비율", "수평", "높이"], label + assert out["label"]["fill"].startswith("길이 ") diff --git a/resources/tester/test_196_1_b06_notice_chips.py b/resources/tester/test_196_1_b06_notice_chips.py new file mode 100644 index 000000000..7027d4783 --- /dev/null +++ b/resources/tester/test_196_1_b06_notice_chips.py @@ -0,0 +1,195 @@ +"""196-1 B06 횡단 맨 위 알림 줄 — 칩 꼴 · 짧은 이름 + 수 · 긴 설명 = 호버 · 내용 칸 하나 공유. + +`B06_Section_UI_Cross_Notice_Chips.ts` 를 Node 로 그대로 돌림(작은 가짜 DOM · 호버는 받은 글만 기록). +줄 짜임(sticky · 열 수 칸 · 카드 scroll-margin)과 이름 · 설명 자리는 원문 대조. +""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TS = ROOT / "config" / "node_modules" / "typescript" +B06 = ROOT / "B06_Section" +MOD = B06 / "B06_Section_UI_Cross_Notice_Chips.ts" + +PRELUDE = f""" +const fs = require("fs"); +const ts = require({json.dumps(str(TS))}); +const js = ts.transpileModule(fs.readFileSync({json.dumps(str(MOD))}, "utf8"), {{ + compilerOptions: {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }}, +}}).outputText; +class El {{ + constructor(tag) {{ + this.tag = tag; this.parentElement = null; this.children = []; this.on = {{}}; + this.attrs = {{}}; this.dataset = {{}}; this.hidden = false; this.textContent = ""; this.cls = new Set(); + const cls = this.cls; + this.classList = {{ toggle: (c, on) => (on ? cls.add(c) : cls.delete(c)), contains: (c) => cls.has(c) }}; + }} + set className(v) {{ this.cls.clear(); v.split(/\\s+/).filter(Boolean).forEach((c) => this.cls.add(c)); }} + get className() {{ return [...this.cls].join(" "); }} + append(...cs) {{ cs.forEach((c) => {{ c.parentElement = this; this.children.push(c); }}); }} + replaceChildren(...cs) {{ this.children = []; this.append(...cs); }} + setAttribute(k, v) {{ this.attrs[k] = String(v); }} + getAttribute(k) {{ return this.attrs[k] ?? null; }} + addEventListener(t, f) {{ (this.on[t] ||= []).push(f); }} + closest(sel) {{ + for (let x = this; x; x = x.parentElement) + if (sel.startsWith(".") ? x.cls.has(sel.slice(1)) : x.tag === sel) return x; + return null; + }} + click() {{ + const e = {{ type: "click", target: this }}; + for (let x = this; x; x = x.parentElement) (x.on.click || []).forEach((f) => f(e)); + }} +}} +const document = {{ createElement: (t) => new El(t) }}; +const hovers = []; +const fakeRequire = (name) => {{ + if (name !== "@ui/ui_template_hover_info") throw new Error("뜻밖의 import " + name); + return {{ attachHoverInfo: (root, text, sel) => hovers.push({{ root, text, sel }}) }}; +}}; +const mod = {{ exports: {{}} }}; +new Function("exports", "module", "require", "document", js)(mod.exports, mod, fakeRequire, document); +const picks = []; +const bar = mod.exports.createNoticeChips((id) => picks.push(id)); +const [chipsRow, panel] = bar.root.children; +const chip = (key) => chipsRow.children.find((c) => c.dataset.noticeKey === key); +const view = () => ({{ + chips: chipsRow.children.map((c) => [c.textContent, c.getAttribute("aria-expanded"), c.className]), + panelHidden: panel.hidden, + panelInfo: panel.classList.contains("is-info"), + rows: panel.children.map((b) => [b.textContent, b.dataset.stationId]), +}}); +const row = (id) => ({{ stationId: id, text: "STA " + id }}); +const items = (fill, pave, berm) => [ + {{ key: "fill", label: "성토 5m↑", hint: "긴 성토 설명", tone: "warn", rows: fill.map(row) }}, + {{ key: "pave", label: "포장 권장", hint: "긴 포장 설명", tone: "warn", rows: pave.map(row) }}, + {{ key: "berm", label: "소단 검토", hint: "긴 소단 설명", tone: "info", rows: berm.map(row) }}, +]; +const out = {{}}; +""" + + +def _node(body: str): + script = PRELUDE + body + "\nconsole.log(JSON.stringify(out));" + got = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60 + ) + assert got.returncode == 0, got.stderr + return json.loads(got.stdout) + + +def test_칩_이름은_짧은_이름_더하기_측점_수_0이면_안_보임(): + got = _node( + """ +bar.update(items(["a", "b", "c"], [], ["a", "d", "e", "f", "g"])); +out.v = view(); +out.bar = bar.root.className; +out.tags = chipsRow.children.map((c) => [c.tag, c.type]); +""" + ) + assert got["v"]["chips"] == [ + ["성토 5m↑ 3", "false", "b06-section__notice-chip b06-section__notice-chip--warn"], + ["소단 검토 5", "false", "b06-section__notice-chip b06-section__notice-chip--info"], + ] + assert got["tags"] == [["button", "button"], ["button", "button"]] + assert got["v"]["panelHidden"] is True and got["v"]["rows"] == [] + + +def test_긴_설명은_공용_호버로_칩마다(): + got = _node( + """ +bar.update(items(["a"], ["b"], ["c"])); +out.count = hovers.length; +out.root = hovers[0].root === chipsRow; +out.sel = hovers[0].sel; +out.hints = chipsRow.children.map((c) => hovers[0].text(c)); +out.other = hovers[0].text({ dataset: {} }) ?? null; +""" + ) + assert got["count"] == 1 and got["root"] and got["sel"] == ".b06-section__notice-chip" + assert got["hints"] == ["긴 성토 설명", "긴 포장 설명", "긴 소단 설명"] + assert got["other"] is None + + +def test_내용_칸_하나_한_번에_하나_다시_누르면_닫힘(): + got = _node( + """ +bar.update(items(["a", "b"], ["p"], ["c"])); +out.panels = bar.root.children.length; +chip("fill").click(); +out.open1 = view(); +chip("berm").click(); +out.open2 = view(); +chip("berm").click(); +out.closed = view(); +""" + ) + assert got["panels"] == 2 # 칩 줄 + 내용 칸 하나 + o1 = got["open1"] + assert o1["panelHidden"] is False and o1["panelInfo"] is False + assert o1["rows"] == [["STA a", "a"], ["STA b", "b"]] + assert [c[1] for c in o1["chips"]] == ["true", "false", "false"] + o2 = got["open2"] + assert o2["rows"] == [["STA c", "c"]] and o2["panelInfo"] is True + assert [c[1] for c in o2["chips"]] == ["false", "false", "true"] + c = got["closed"] + assert c["panelHidden"] is True and c["rows"] == [] + assert [x[1] for x in c["chips"]] == ["false", "false", "false"] + + +def test_측점_누르면_그_카드로_다시_그려도_연_칩_유지_0이면_닫힘(): + got = _node( + """ +bar.update(items(["a", "b"], [], [])); +chip("fill").click(); +panel.children[1].click(); +chipsRow.children[0].click(); chipsRow.children[0].click(); // 칩 누름은 측점 고름 아님 +out.picks = picks.slice(); +bar.update(items(["a", "b", "x"], [], [])); +out.kept = view(); +bar.update(items([], ["p"], [])); +out.gone = view(); +""" + ) + assert got["picks"] == ["b"] + assert got["kept"]["rows"] == [["STA a", "a"], ["STA b", "b"], ["STA x", "x"]] + assert got["kept"]["chips"][0][:2] == ["성토 5m↑ 3", "true"] + assert got["gone"]["panelHidden"] is True and got["gone"]["chips"][0][:2] == [ + "포장 권장 1", + "false", + ] + + +def test_알림_줄은_칩을_쓰고_긴_설명은_호버_짧은_이름은_사전(): + src = (B06 / "B06_Section_UI_Cross_FillSlope_Notice.ts").read_text(encoding="utf-8") + assert " str: + return path.read_text(encoding="utf-8") + + +def _run(script: str, *args: str) -> object: + result = subprocess.run( + [*NODE, "-e", script, *args], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +@needs_node +def test_카드_셋_해시(): + from test_b01_settings_cards import _cards + + cards = _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) + assert [c[2].split("?")[1].split("&")[0] for c in cards[1:]] == [ + "open=구조물조합", + "open=옵션", + "open=도면", + ] + assert all("&root=master" in c[2] for c in cards[1:]) + + +@needs_node +def test_해시_진입_셋(): + layer = _text(M02 / "M02_MasterTemplete_Layer.ts") + fn = re.search(r"^export function entryFromHash\([\s\S]*?^}", layer, re.M) + assert fn, "entryFromHash 를 못 찾음" + opens = re.search(r"^const OPENS[^\n]*", layer, re.M) + roots = re.search(r"^const ROOTS[^\n]*", layer, re.M) + assert opens and roots + script = ( + "const currentQuery = () => new URLSearchParams(process.argv[1]);\n" + "const defaultRoot = () => 'master';\n" + "type Root = string; type OpenTarget = string; type Entry = any; type SessionUser = any;\n" + f"{opens.group(0)}\n{roots.group(0)}\n" + + fn.group(0).replace("export function", "function") + + "\nconsole.log(JSON.stringify(entryFromHash(null)));" + ) + for want in ("구조물조합", "옵션", "도면"): + got = _run(script, f"open={want}&root=master&company=3") + assert got["open"] == want and got["root"] == "master" and got["company"] == "3" + + +def test_reveal_옵션만_줄을_누름(): + side = _text(M02 / "M02_MasterTemplete_UI_Side.ts") + body = side.split("const reveal = ")[1].split("\n };")[0] + assert "if (!sec) return;" in body # 시스템 층 아니면 옵션 · 도면 줄 없음 + assert 'sec.classList.remove("is-collapsed");' in body # 도면 = 펼치기만 + assert ( + 'if (target === "옵션") options.body.querySelector("button")?.click();' in body + ) + # 옵션 컨테이너 안 단추 = 「구조물 옵션 정의」 줄 하나뿐 + options = _text(M02 / "StructOptions" / "M02_SO_Options.ts") + assert options.count('el("button"') == 1 + assert "openDefScreen" in options + # 옵션 컨테이너 = 구조물 옵션 정의 줄 · 시스템 층에만 + assert "옵션: section(options.title, options.body)," in side + page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") + assert 'if (entry.open !== "구조물조합") side.reveal(entry.open);' in page + + +@needs_node +def test_집계표_갈래_기다리는_사이_다른_화면이_열리면_그리지_않음(): + main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") + start = main.index(" async function mount(doc: unknown, at: Selection)") + stop = main.index(' if (at.kind === "labor" || isPrecombo(at.kind)) {', start) + table = main[start:stop] + "\n}\n" + assert table.count("if (mine !== seq)") == 2 # 모듈 받은 뒤 · 번호 받은 뒤 + script = ( + "type Selection = any; type EditorHandle = any; type SheetEditorHandle = any;\n" + "type StructureColumn = any;\n" + "let seq = 0; let panel: any = null; let editor: any = null; let sheet: any = null;\n" + "let dirty = false; const log: string[] = [];\n" + "const STRUCTURE_TABLE = 'T'; const SHEET_MODS = {}; const L = (k: string) => k;\n" + "const loaded: any = null; const extras: any = {};\n" + "const host = { replaceChildren() {} }; const writable = () => true;\n" + "const empty = (m: string) => log.push('empty:' + m);\n" + "const comboView = (d: any) => d; const comboMerge = (_: any, d: any) => d;\n" + "const laborSumText = () => ''; const laborSumState = () => '';\n" + "const gate = (): [Promise, () => void] => {\n" + " let go = () => {}; return [new Promise((r) => (go = r)), () => go()];\n" + "};\n" + "let modGate = gate(); let numGate = gate();\n" + "const findExport = async () => {\n" + " await modGate[0]; return (_h: any, _d: any, _o: any) => (log.push('create')," + " { destroy() {}, getDoc: () => ({}) });\n" + "};\n" + "const buildPanel = () => ({ root: {}, numbers: () => ({}), async reload() {\n" + " await numGate[0]; }, dirty: () => false, destroy: () => log.push('panel.destroy') });\n" + + table + + "\nasync function run(bumpAt: string): Promise {\n" + " seq = 1; log.length = 0; panel = null; modGate = gate(); numGate = gate();\n" + " const going = mount({}, { layer: 'system', kind: 'table', name: STRUCTURE_TABLE," + " projectId: null });\n" + " if (bumpAt === 'mod') seq++;\n" + " modGate[1](); await new Promise((r) => setTimeout(r, 5));\n" + " if (bumpAt === 'num') seq++;\n" + " numGate[1](); await going; return [...log, 'panel:' + (panel ? 'set' : 'null')];\n" + "}\n" + "console.log(JSON.stringify({ none: await run('none'), mod: await run('mod')," + " num: await run('num') }));" + ) + got = _run(script) + assert got["none"] == ["create", "panel:set"] # 안 끼어들면 그대로 그림 + assert got["mod"] == ["panel:null"] # 모듈 기다리는 사이 다른 화면 — 그리지 않음 + assert got["num"] == ["panel.destroy", "panel:null"] # 번호 기다리는 사이 — 패널도 버림 diff --git a/resources/tester/test_198_1_b08_summary_lamp.py b/resources/tester/test_198_1_b08_summary_lamp.py new file mode 100644 index 000000000..184e9396d --- /dev/null +++ b/resources/tester/test_198_1_b08_summary_lamp.py @@ -0,0 +1,114 @@ +"""198-1 B08 구조물 집계표 — 미연결 = 측점 칸 왼쪽 램프 하나 · 「미지정」 열 지움. + +- 칸 class: 화면 소스의 `cellClass` 를 떼어 공용 표 `renderDataTableHtml` 로 그림(Node) — 미연결 줄 측점 칸만 램프 +- 호버 글: 화면 소스의 호버 함수를 떼어 가짜 칸으로 부름 — 「구조물 조합 미연결 — 빈 칸 이름」 · 연결 줄은 없음 +- CSS: 램프 = 경고 노랑 토큰(177-1) · 밝음 · 어두움 둘 다 +- 줄 값 = 측점 + 서버 값뿐(「미지정」 · 「· 미연결」 글 없음 → 수량 · 합계 그대로) +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TS = ROOT / "config" / "node_modules" / "typescript" +UI = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + +PRELUDE = f""" +const fs = require("fs"); +const ts = require({json.dumps(str(TS))}); +const js = (code) => ts.transpileModule(code, {{ + compilerOptions: {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }}, +}}).outputText; +""" + + +def _node(script: str): + out = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60 + ) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +def _lamp_class() -> str: + m = re.search(r'const LAMP_CLASS = "([^"]+)";', UI) + assert m + return m.group(1) + + +def test_미연결_줄_측점_칸만_램프(): + cell = re.search(r"cellClass: (\(i, key\) => .*?),\n", UI) + assert cell + table = json.dumps(str(ROOT / "ui_template" / "ui_template_data_table.ts")) + got = _node( + PRELUDE + + f""" +const box = {{ exports: {{}} }}; +new Function("exports", "module", "require", js(fs.readFileSync({table}, "utf8")))( + box.exports, box, (p) => (p.endsWith(".css") ? {{}} : require(p))); +const LAMP_CLASS = {json.dumps(_lamp_class())}; +const missing = ["", "BOX", ""]; +const cellClass = {cell.group(1)}; +const html = box.exports.renderDataTableHtml({{ + columns: [{{ key: "station", label: "측점" }}, {{ key: "c1", label: "조합" }}], + rows: [{{ station: "0+000" }}, {{ station: "0+020" }}, {{ station: "0+040", c1: 1 }}], + frozen: 1, empty: "", cellClass }}); +const rows = html.split("")[1].split(""); +console.log(JSON.stringify({{ + count: html.split(LAMP_CLASS).length - 1, + row1: rows[1], + row0: rows[0], +}})); +""" + ) + assert got["count"] == 1 + assert f' \{.*?\n\s*\}),\n", UI, re.S + ) + assert hover + got = _node( + PRELUDE + + f""" +const L = (k) => ({{ B08_Quantity_Summary_LampTip: "구조물 조합 미연결" }})[k]; +const missing = ["", "배관: 본체 · 관 / 세월교: 유출 구조"]; +const text = eval(js("(" + {json.dumps(hover.group(1))} + ")")); +const cell = (i) => ({{ closest: () => ({{ dataset: {{ i: String(i) }} }}) }}); +console.log(JSON.stringify([text(cell(1)), text(cell(0)) || null])); +""" + ) + assert got == ["구조물 조합 미연결 — 배관: 본체 · 관 / 세월교: 유출 구조", None] + assert 'import { attachHoverInfo } from "@ui/ui_template_hover_info";' in UI + assert "`td.${LAMP_CLASS}`," in UI # 램프 칸에만 + locale = (ROOT / "ui_template" / "ui_template_locale_b4.ts").read_text(encoding="utf-8") + assert 'B08_Quantity_Summary_LampTip: ["구조물 조합 미연결",' in locale + + +def test_미지정_열_없음_줄_값_그대로(): + assert 'key: "unassigned"' not in UI and "B08_Quantity_Summary_Unassigned" not in UI + assert 'L("B08_Quantity_Summary_Unlinked")' not in UI # 측점 「· 미연결」 글 지움 + assert "frozen: 1," in UI + rows = re.search(r"rows: stations\.map\(\(station, i\) => \(\{(.*?)\}\)\),", UI, re.S) + assert rows and [s.strip() for s in rows.group(1).strip().split("\n")] == [ + "station: labels[i],", + "...values.get(stationKey(station)),", + ] + + +def test_램프_경고_노랑_토큰(): + css = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.css").read_text(encoding="utf-8") + m = re.search( + r'\[data-b08-summary="table"\] td\.' + re.escape(_lamp_class()) + r"::before\s*\{([^}]*)\}", + css, + ) + assert m + assert re.search(r"background:\s*var\(--color-warning\)", m.group(1)) + assert "border-radius: 50%" in m.group(1) + theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text(encoding="utf-8") + assert len(re.findall(r"--color-warning:\s*#", theme)) >= 2 # 밝음 · 어두움 diff --git a/resources/tester/test_199_1_fill_viewport.py b/resources/tester/test_199_1_fill_viewport.py new file mode 100644 index 000000000..ede12aa32 --- /dev/null +++ b/resources/tester/test_199_1_fill_viewport.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +"""PLAN 199-1 — B08 표 칸 최대 높이 = 브라우저 창 아래 끝까지. + +Node 헬퍼(`helper_ui_fill_viewport.cjs`)가 `ui_template_data_table.ts` 의 창 끝까지 셈을 가짜 DOM 에서 돌리고, +여기서 옵션 · 셈 · 창 크기 바뀜 · 합계 줄 고정 · B08 적용을 판정함. +""" + +import json +import os +import re +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.normpath(os.path.join(HERE, "..", "..")) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as f: + return f.read() + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_ui_fill_viewport.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_최대_높이_창_기준_셈(r): + assert r["clamp"] == 900 - 200 - 68 + assert r["small"] == 120 # 아주 작은 창에서도 머리 + 몇 줄은 남김 + assert r["below"] == r["belowExpected"] == 80 # 뒤 형제 + 윗대 아래 여백 + + +def test_붙은_뒤에_셈(r): + assert r["beforeAttach"] is None # 문서에 붙기 전엔 안 건드림 + assert r["observed"] # 칸 + 문서 본문(body) 둘 다 관찰 + assert r["first"] == f"{900 - 200 - 80}px" # 620 + assert r["fits"] >= 1 + + +def test_창_크기_바뀌면_다시_셈(r): + assert r["afterResize"] == f"{600 - 200 - 80}px" # 320 + + +def test_스크롤_위치와_무관(r): + assert r["afterScroll"] == r["afterResize"] + + +def test_아래_요소가_늘면_그만큼_줄어듦(r): + assert r["afterNoteGrow"] == f"{600 - 200 - 140}px" # 260 + + +def test_칸이_떨어지면_창_청취_해제(r): + assert r["listenersAfterDetach"] == 0 + + +def test_공용_표_옵션과_연결(): + ts = _read("ui_template", "ui_template_data_table.ts") + assert re.search(r"fillViewport\?: boolean", ts) + assert re.search(r"if \(opts\.fillViewport\) fillViewport\(root, \(\) => draw\(false\)\)", ts) + + +def test_합계_줄_고정_그대로(): + css = _read("ui_template", "ui_template_data_table.css") + assert re.search(r"tfoot[^{]*\{[^}]*position:\s*sticky[^}]*bottom:\s*0", css, re.S) + assert "max-height: calc(100vh - 260px)" in css # 옵션 안 준 표의 기본값은 그대로 + + +@pytest.mark.parametrize( + "name, count", + [ + ("B08_Quantity_UI_Summary.ts", 1), + ("B08_Quantity_UI_Earthwork.ts", 1), + ("B08_Quantity_UI_Earthwork_Sheets.ts", 2), + ("B08_Quantity_UI_Material.ts", 1), + ], +) +def test_B08_표_모두_적용(name, count): + src = _read("B08_Quantity", name) + assert src.count("fillViewport: true") == count == src.count("createDataTable({") diff --git a/resources/tester/test_54_26_slope_table09.py b/resources/tester/test_54_26_slope_table09.py index d39729a73..c848eb74d 100644 --- a/resources/tester/test_54_26_slope_table09.py +++ b/resources/tester/test_54_26_slope_table09.py @@ -28,7 +28,11 @@ TABLE = { "옹벽", ["wall_form", "length_m", "height_m", "before_m", "after_m"], ), - "berm": ("gbermdetail", "상세 내용", ["width_m", "depth_m"]), # 높이 칸 숨김 · 깊이(178-2) + "berm": ( + "gbermdetail", + "상세 내용", + ["width_m"], + ), # 높이 · 깊이 칸 숨김(깊이 = 횡단도 ▲▼ · 194-1) } SOIL_FORMS = ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"] diff --git a/resources/tester/test_61_1_b08_summary_table.py b/resources/tester/test_61_1_b08_summary_table.py index 8922b2a81..c7454065a 100644 --- a/resources/tester/test_61_1_b08_summary_table.py +++ b/resources/tester/test_61_1_b08_summary_table.py @@ -96,5 +96,5 @@ def test_집계표는_공용_표(): # PLAN 70-2 — 공용 많은 자료 표 · 측점 열 고정 · 칸은 빈칸(「—」 아님) ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") assert "createDataTable(" in ui and "@ui/ui_template_data_table" in ui - assert "frozen: hasMissing ? 2 : 1" in ui and 'empty: ""' in ui and "onRowClick" in ui + assert "frozen: 1," in ui and 'empty: ""' in ui and "onRowClick" in ui assert 'document.createElement("table")' not in ui diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 13e7a3b54..33656ca10 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -10,6 +10,7 @@ * 고칠 칸(PLAN 121) = editable(줄, 열) 참인 칸만 글 상자 · 바꾸면 onEdit(줄, 열, 글) — 다시 그려도 커서 자리 지킴 * 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조) * 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로 + * 창 끝까지(PLAN 199) = fillViewport → 표 칸 최대 높이를 창 아래 끝까지(줄이 늘어도 페이지 스크롤 0) * ========================================================================== */ import "./ui_template_data_table.css"; @@ -62,6 +63,8 @@ export interface DataTableOptions { rowClass?: (index: number) => string | null | undefined; /** 칸 꼴 class(본문 줄 · 글 상자 아닌 칸만 · 예 `is-picked` = 고른 칸 강조) — 빈 값이면 안 붙임 */ cellClass?: (index: number, key: string) => string | null | undefined; + /** 참이면 표 칸 최대 높이 = 창 아래 끝까지(창 크기 바뀌면 다시 셈) — 안 주면 CSS 고정값 */ + fillViewport?: boolean; } export interface DataTable { @@ -240,6 +243,63 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin /** 화면 위아래로 더 그려 둘 줄 수 · 구간은 이 단위로 끊어 스크롤마다 다시 그리지 않음 */ const OVER = 40; +/* ── 창 아래 끝까지(PLAN 199) — 칸 최대 높이 = 창 높이 − 칸 위쪽 자리 − 아래 자리 ──────────── + * 아래 자리 = 칸 뒤 형제 요소 높이 + 윗대 요소의 아래 안쪽 여백 · 테두리 · 바깥 여백. + * 줄이 적으면 내용만큼 · 창 크기 · 위아래 요소가 바뀌면 다시 셈 · 칸이 문서에서 떨어지면 스스로 멈춤. + * ------------------------------------------------------------------------------------------ */ + +/** 칸이 이보다 작아지지 않게(아주 작은 창에서 머리만 남는 것 방지) */ +const MIN_HEIGHT = 120; + +/** 창 기준 최대 높이(px) — `top` = 문서 맨 위에서 칸 위까지 · `below` = 칸 아래로 남겨야 할 자리 */ +export function fillHeight(viewport: number, top: number, below: number): number { + return Math.max(MIN_HEIGHT, Math.floor(viewport - top - below)); +} + +const px = (v: string): number => parseFloat(v) || 0; + +/** 칸 아래로 문서 끝까지 차지하는 자리 */ +export function spaceBelow(el: HTMLElement): number { + let sum = 0; + for (let node: HTMLElement = el; node.parentElement; node = node.parentElement) { + const parent = node.parentElement; + const own = getComputedStyle(node); + const around = getComputedStyle(parent); + const gap = px(around.rowGap); + for (let s = node.nextElementSibling; s; s = s.nextElementSibling) { + const h = s.getBoundingClientRect().height; + if (h) sum += h + gap; + } + sum += px(own.marginBottom) + px(around.paddingBottom) + px(around.borderBottomWidth); + } + return sum; +} + +export function fillViewport(el: HTMLElement, onFit?: () => void): () => void { + let seen = false; // 한 번이라도 문서에 붙었는지 — 붙었다가 떨어지면 멈춤 + let observer: ResizeObserver | null = null; + const fit = (): void => { + if (!el.isConnected) { + if (seen) stop(); + return; + } + seen = true; + const top = el.getBoundingClientRect().top + window.scrollY; + el.style.maxHeight = `${fillHeight(window.innerHeight, top, spaceBelow(el))}px`; + onFit?.(); + }; + const stop = (): void => { + window.removeEventListener("resize", fit); + observer?.disconnect(); + }; + window.addEventListener("resize", fit); + // 칸이 붙을 때 · 줄이 늘 때 · 위아래 요소가 바뀔 때(문서 높이) 다시 셈 + observer = new ResizeObserver(fit); + observer.observe(el); + observer.observe(document.body); + return stop; +} + export function createDataTable(opts: DataTableOptions): DataTable { const root = document.createElement("div"); root.className = "ui-data-table"; @@ -287,6 +347,7 @@ export function createDataTable(opts: DataTableOptions): DataTable { state.onRowClick?.(Number(row.dataset.i)); }); draw(true); + if (opts.fillViewport) fillViewport(root, () => draw(false)); return { root, setRows(rows, total) { diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index a97c57165..d03048056 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -472,7 +472,9 @@ export const ui_locales_b2 = { "측구를 생성한 경우에만 형식을 선택할 수 있습니다.", "Available only when a ditch is created.", ], + B06_Notice_FillSlope: ["성토 5m↑", "Fill slope >5m"], B06_Notice_PaveSuggested: ["포장 권장", "Pavement recommended"], + B06_Notice_Berm: ["소단 검토", "Berm review"], B06_Cross_Columns: ["가로 개수", "Per row"], B06_Design_Detail: ["상세", "Detail"], B06_Design_Paved_Suggested: [ diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index bc300a4ee..4dd32bf14 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -63,7 +63,7 @@ export const ui_locales_b4 = { ], B08_Quantity_Summary_Total: ["계", "Total"], B08_Quantity_Summary_Unlinked: ["미연결", "Unlinked"], - B08_Quantity_Summary_Unassigned: ["미지정", "Unassigned"], + B08_Quantity_Summary_LampTip: ["구조물 조합 미연결", "Structure combination unlinked"], B08_Quantity_Link_Title: ["조합 연결", "Link combinations"], B08_Quantity_Link_Body: ["본체", "Body"], B08_Quantity_Link_Linked: ["연결됨", "Linked"],