diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts b/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts index 99d3005a..d2bc8dbd 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts @@ -328,6 +328,8 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou selectionListener?.(null); return; } + // 빈 공간·경로점 클릭: 측점 선택을 해제해 하이라이트·하단 값 열 오버레이를 초기화한다(D-4). + if (selectedStationId !== null) selectStation(null); selectedId = typeof object?.userData.routePointId === "string" ? object.userData.routePointId : null; renderMarkers(); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index 1f6c7fac..01660763 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -204,16 +204,23 @@ export interface CrossDesign { | { type: "standard"; top_width_m: number; bottom_width_m: number; depth_m: number } | { type: "l_type"; width_m: number; depth_m: number } | { type: "none" }; + /** 측구 생성 여부(엔진이 자동/override 반영해 실제 적용한 결과). */ + ditch_enabled?: boolean; /** 포장 중첩 여부와 포장층 두께(포장 시). */ paved: boolean; pavement_thickness_m?: number; /** B05 법정 경사 분석의 포장 제안 여부(사용자 토글과 무관하게 유지). */ pavement_suggested?: boolean; - /** 노면 양 끝점(포장층 박스·노면 렌더링 기준). */ + /** 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. */ road_edges: { left: { offset_m: number; elevation_m: number }; right: { offset_m: number; elevation_m: number }; }; + /** 차도(노견 제외) 양 끝점 — 포장 범위 기준. */ + carriageway_edges?: { + left: { offset_m: number; elevation_m: number }; + right: { offset_m: number; elevation_m: number }; + }; design_elevation_m: number; cut_area_m2: number; fill_area_m2: number; @@ -242,6 +249,8 @@ export interface CrossDesignRequest { rock_boundary_offset_m?: number | null; /** 암 지반 2단계 경사 적용 여부(기본 true, 토글로 해제). */ two_stage_slope?: boolean; + /** 측구 생성 여부. null/미지정=자동 판정, true/false=수동 override. */ + ditch_enabled?: boolean | null; /** 설정 패널 편집값. 요청값 → config 기본값 순으로 우선한다. */ standard_cross_section?: StandardCrossSection; } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py index 2a9b50d1..c64bcf78 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py @@ -175,8 +175,10 @@ class _SectionGeometry: soil_cut_ratio: float | None = None, rock_boundary_offset_m: float | None = None, two_stage_slope: bool = False, + ditch_enabled: bool | None = None, ) -> None: half_road = group["road_width_m"] / 2.0 + self.half_road = half_road # 차도 반폭(노견 제외) — 포장 범위 기준 self.left_extent = half_road + group["shoulder_left_m"] # 좌(+) 노면 끝 self.right_extent = half_road + group["shoulder_right_m"] # 우(-) 노면 끝 self.z_center = design_elevation_m @@ -193,25 +195,37 @@ class _SectionGeometry: self._ground_at = ground_at self._rock_offset = rock_boundary_offset_m or 0.0 self._rock_knee: dict[str, tuple[float, float] | None] = {} - # 성토만(양성)이면 측구 없음(합의). 절토가 있는 단면만 측구를 판다. - self.has_ditch = section_mode != "both_fill" self.ditch_type = ditch_type # 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약). slope = cross_slope_pct / 100.0 self.slope_per_offset = -slope if ditch_side == "left" else slope - # 편절편성에서 계획고가 지반보다 많이 낮으면 성토측 지반이 노면 끝보다 높아 - # 실제로는 양측 절토가 된다. 그 측 역할을 절토로 자동 전환한다(측구 위치는 - # ditch_side 그대로 유지, 전환된 측도 2단 경사 대상). 양절·양성(사용자 명시)은 - # 손대지 않는다. - if section_mode in ("left_cut", "right_cut") and ground_at is not None: - for side, edge in (("left", self.left_extent), ("right", -self.right_extent)): - role = self.left_role if side == "left" else self.right_role - if role == "fill" and ground_at(edge) > self.road_z(edge) + 1e-3: - if side == "left": - self.left_role = "cut" - else: - self.right_role = "cut" + # 단면 유형 자동 판정(D-2): 각 측 절/성토 역할을 노면 끝 지반이 설계면보다 + # 높은지(절토)/낮은지(성토)로 결정한다. 좌절/우절/양절/양성이 모두 지형에서 + # 자연 도출된다. 사용자 입력 section_mode는 측구 방향(ditch_side) 기본값에만 쓰고 + # 절/성토 역할은 손대지 않는다. ground_at이 없으면 section_mode 기반 역할을 쓴다. + if ground_at is not None: + self.left_role = ( + "cut" if ground_at(self.left_extent) > self.road_z(self.left_extent) + 1e-3 + else "fill" + ) + self.right_role = ( + "cut" if ground_at(-self.right_extent) > self.road_z(-self.right_extent) + 1e-3 + else "fill" + ) + + # 측구 생성 여부(D-1): 양성은 항상 미생성. ditch_enabled가 오면 그 값을 따르고(수동 + # override), None이면 자동 판정 — 측구측 노면 끝에서 지반이 설계면보다 높으면(절토 + # 상황) 생성, 낮으면(성토 상황, 자연 배수) 미생성. ground_at 없으면 보수적으로 생성. + if section_mode == "both_fill": + self.has_ditch = False + elif ditch_enabled is not None: + self.has_ditch = ditch_enabled + elif ground_at is not None: + ditch_edge = self.left_extent if ditch_side == "left" else -self.right_extent + self.has_ditch = ground_at(ditch_edge) > self.road_z(ditch_edge) + 1e-3 + else: + self.has_ditch = True # 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용). self.ditch_points: list[tuple[float, float]] = [] @@ -363,6 +377,7 @@ def compute_cross_design( standard: dict[str, Any] | None = None, rock_boundary_offset_m: float | None = None, two_stage_slope: bool = True, + ditch_enabled: bool | None = None, ) -> dict[str, Any]: """측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다. @@ -422,6 +437,7 @@ def compute_cross_design( soil_cut_ratio=soil_cut_ratio, rock_boundary_offset_m=rock_boundary_offset_m, two_stage_slope=enable_two_stage, + ditch_enabled=ditch_enabled, ) # 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야 @@ -468,10 +484,20 @@ def compute_cross_design( "depth_m": group["ditch_depth_m"], } + # 자동 판정된 절/성토 역할에서 실제 단면 유형을 도출해 echo한다(D-2, 표시·저장용). + if geometry.left_role == "cut" and geometry.right_role == "cut": + resolved_mode = "both_cut" + elif geometry.left_role == "fill" and geometry.right_role == "fill": + resolved_mode = "both_fill" + elif geometry.left_role == "cut": + resolved_mode = "left_cut" + else: + resolved_mode = "right_cut" + result: dict[str, Any] = { "ground_type": ground_type, "geometry_preset": preset_key, - "section_mode": section_mode, + "section_mode": resolved_mode, "ditch_side": resolved_ditch_side, "ditch_type": ditch_type if geometry.has_ditch else None, "cut_slope_ratio": round(geometry.cut_ratio, 4), @@ -482,8 +508,9 @@ def compute_cross_design( "carriageway_width_m": round(group["road_width_m"], 4), "cross_slope_pct": round(cross_slope_pct, 4), "ditch": ditch_spec, + "ditch_enabled": bool(geometry.has_ditch), "paved": bool(paved), - # 노면 양 끝점(프론트 포장층 박스·노면 렌더링 기준). + # 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. "road_edges": { "left": { "offset_m": round(geometry.left_extent, 4), @@ -494,6 +521,17 @@ def compute_cross_design( "elevation_m": round(geometry.road_z(-geometry.right_extent), 4), }, }, + # 차도(노견 제외) 양 끝점 — 포장 범위 기준(D-5). + "carriageway_edges": { + "left": { + "offset_m": round(geometry.half_road, 4), + "elevation_m": round(geometry.road_z(geometry.half_road), 4), + }, + "right": { + "offset_m": round(-geometry.half_road, 4), + "elevation_m": round(geometry.road_z(-geometry.half_road), 4), + }, + }, "design_elevation_m": round(float(design_elevation_m), 4), "cut_area_m2": round(cut_area, 4), "fill_area_m2": round(fill_area, 4), diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index cc258675..4907c8fe 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -18,6 +18,7 @@ from B05_wf2_Route.B05_wf2_Route_Engine_Sections import ( run_section_generation, ) from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions +from B05_wf2_Route.B05_wf2_Route_Router_Confirm import _merge_uphill_overrides_into_longitudinal from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Design import ( compute_cross_design, design_elevation_from_longitudinal, @@ -534,6 +535,7 @@ async def compute_cross_section_design( standard=request.standard_cross_section, rock_boundary_offset_m=request.rock_boundary_offset_m, two_stage_slope=request.two_stage_slope, + ditch_enabled=request.ditch_enabled, ) # B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다. design["status"] = "provisional" @@ -645,6 +647,31 @@ async def confirm_sections( except Exception: await connection.rollback() raise + + # 측구 방향(design.ditch_side) 변경을 B05 종단 정본 stations.uphill_side에 역반영한다(E-7). + # 파일 기반·비치명적: 실패해도 확정은 유지한다. + try: + async with pool.acquire() as connection: + designs = await get_cross_section_designs(connection, route_id) + overrides = [ + {"chainage_m": record["chainage_m"], "side": record["design"]["ditch_side"]} + for record in designs + if isinstance(record.get("design"), dict) + and record["design"].get("ditch_side") in ("left", "right") + ] + if overrides: + await asyncio.to_thread( + _merge_uphill_overrides_into_longitudinal, + Path(resolve_stored_project_path(stored_path)), + str(existing["longitudinal_file_path"]), + overrides, + ) + except Exception: + logger.exception( + "B06 측구 방향 B05 역반영 실패 (확정은 유지): project_id=%s route_id=%s", + project_id, + route_id, + ) return SectionConfirmResponse(project_id=str(project_id), route_id=route_id) except Exception: logger.exception("B06 종횡단 확정 실패: project_id=%s", project_id) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py index 49506624..12582da1 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py @@ -52,6 +52,8 @@ class CrossDesignRequest(BaseModel): rock_boundary_offset_m: float | None = None # 암 지반 2단계 경사(암반 경계 아래=암 경사, 위=토사 경사) 적용 여부. 토글로 해제 가능. two_stage_slope: bool = True + # 측구 생성 여부. None=자동 판정(측구측 절토면만 생성), True/False=수동 override. + ditch_enabled: bool | None = None # B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 기본값 순. standard_cross_section: dict[str, Any] | None = None diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts index fb5356a9..83c31a6a 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts @@ -41,6 +41,12 @@ const MODE_OPTIONS: Array<[SectionMode, keyof typeof ui_locales]> = [ ["both_cut", "B06_Design_Mode_BothCut"], ["both_fill", "B06_Design_Mode_BothFill"], ]; + +/** 자동 판정된 단면 유형의 지역화 라벨(제목행 pill용, D-2/E-3). */ +export function sectionModeLabel(mode: SectionMode | undefined): string { + const key = MODE_OPTIONS.find(([value]) => value === mode)?.[1]; + return key ? ui_locales[key][currentLanguageIndex] : "-"; +} const DITCH_OPTIONS: Array<[DitchSide, keyof typeof ui_locales]> = [ ["left", "B06_Design_Ditch_Left"], ["right", "B06_Design_Ditch_Right"], @@ -58,6 +64,8 @@ export interface CrossDesignChange { paved: boolean; /** 암 지반 2단계 경사(암반 경계 아래=암, 위=토사) 적용 여부. 기본 true, 토글로 해제. */ two_stage_slope: boolean; + /** 측구 생성 여부. null=자동 판정, true/false=수동 override. */ + ditch_enabled: boolean | null; } /** @@ -82,13 +90,18 @@ function segment( options: Array<[T, keyof typeof ui_locales]>, selected: T | null, onPick: (value: T) => void, + disabled = false, + reason = "", ): HTMLElement { const wrap = document.createElement("div"); - wrap.className = "b06-design__seg"; - const legendEl = document.createElement("span"); - legendEl.className = "b06-design__seg-legend"; - legendEl.textContent = legend; - wrap.append(legendEl); + wrap.className = `b06-design__seg${disabled ? " b06-design__seg--disabled" : ""}`; + // 라벨(legend)이 빈 문자열이면 생략한다(E-7: 일부 옵션 라벨 삭제). + if (legend) { + const legendEl = document.createElement("span"); + legendEl.className = "b06-design__seg-legend"; + legendEl.textContent = legend; + wrap.append(legendEl); + } const group = document.createElement("div"); group.className = "b06-design__seg-buttons"; for (const [value, labelKey] of options) { @@ -97,7 +110,10 @@ function segment( button.className = `b06-design__btn${value === selected ? " b06-design__btn--active" : ""}`; button.textContent = L(labelKey); button.setAttribute("aria-pressed", value === selected ? "true" : "false"); - button.addEventListener("click", () => onPick(value)); + // 선행 조건 미충족 시 비활성(상시 노출하되 클릭 불가·사유 툴팁) — E-6. + button.disabled = disabled; + if (disabled && reason) button.title = reason; + else button.addEventListener("click", () => onPick(value)); group.append(button); } wrap.append(group); @@ -111,12 +127,17 @@ function toggle( onLabel: string, offLabel: string, onToggle: () => void, + disabled = false, + reason = "", ): { wrap: HTMLElement; button: HTMLButtonElement } { const wrap = document.createElement("div"); - wrap.className = "b06-design__seg"; - const legendEl = document.createElement("span"); - legendEl.className = "b06-design__seg-legend"; - legendEl.textContent = legend; + wrap.className = `b06-design__seg${disabled ? " b06-design__seg--disabled" : ""}`; + if (legend) { + const legendEl = document.createElement("span"); + legendEl.className = "b06-design__seg-legend"; + legendEl.textContent = legend; + wrap.append(legendEl); + } const buttons = document.createElement("div"); buttons.className = "b06-design__seg-buttons"; const button = document.createElement("button"); @@ -124,14 +145,19 @@ function toggle( button.className = `b06-design__btn${on ? " b06-design__btn--active" : ""}`; button.textContent = on ? onLabel : offLabel; button.setAttribute("aria-pressed", on ? "true" : "false"); - button.addEventListener("click", onToggle); + button.disabled = disabled; + if (disabled && reason) button.title = reason; + else button.addEventListener("click", onToggle); buttons.append(button); - wrap.append(legendEl, buttons); + wrap.append(buttons); return { wrap, button }; } -/** 암 경계선 상/하/리셋 컨트롤(B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용). */ -function rockBoundaryRow(section: CrossSection, control: RockBoundaryControl): HTMLElement { +/** 암 경계선 상/하/리셋 컨트롤(B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용). E-7: X축 행 배치용 export. */ +export function buildRockBoundaryControl( + section: CrossSection, + control: RockBoundaryControl, +): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b06-design__seg b06-design__rockb"; const legendEl = document.createElement("span"); @@ -177,12 +203,14 @@ function rockBoundaryRow(section: CrossSection, control: RockBoundaryControl): H return wrap; } -/** 카드 헤더용 설계 지정 컨트롤 바를 만든다. */ +/** + * 카드 설계 지정 컨트롤을 만든다. 지반유형 세그먼트는 제목행 배치용으로 분리 반환하고(D-6), + * 나머지(단면유형·측구·2단·포장·암경계)는 본문 `bar`에 담는다. + */ export function buildDesignControls( section: CrossSection, onChange: (chainageM: number, change: CrossDesignChange) => void, - rockBoundary?: RockBoundaryControl, -): HTMLElement { +): { bar: HTMLElement; groundSegment: HTMLElement } { const design = section.design; // 기본값: 토사(soil) + 상단측 절토(uphill_side, 미상이면 좌절토) + 일반측구 + 비포장. const state: { @@ -192,20 +220,24 @@ export function buildDesignControls( ditchType: DitchType; paved: boolean; twoStage: boolean; + ditchEnabled: boolean | null; } = { ground: design?.ground_type ?? "soil", mode: design?.section_mode ?? (section.uphill_side === "right" ? "right_cut" : "left_cut"), ditch: design?.ditch_side ?? null, ditchType: design?.ditch_type ?? "standard", paved: design?.paved ?? false, - twoStage: design?.two_stage_slope ?? true, + // 암 design일 때만 저장값을 신뢰(토사는 two_stage=false echo가 무의미) — 암 전환 시 기본 복합경사(4번). + twoStage: design && isRock(design.ground_type) ? (design.two_stage_slope ?? true) : true, + ditchEnabled: design?.ditch_enabled ?? null, }; const bar = document.createElement("div"); bar.className = "b06-design"; // 컨트롤 상호작용이 카드 선택 클릭으로 전파되지 않게 한다. bar.addEventListener("click", (event) => event.stopPropagation()); - const needsDitch = (): boolean => state.mode === "both_cut" || state.mode === "both_fill"; + // 측구 방향 선택이 필요한 경우 = 양절(both_cut). 편절은 절토측 자동, 양성은 측구 없음. + const needsDitch = (): boolean => state.mode === "both_cut"; const emit = (): void => { if (!state.ground || !state.mode) return; // L형 측구는 암 전용 — 토사로 되돌리면 일반측구로 강등해 서버 거부를 예방한다. @@ -217,51 +249,87 @@ export function buildDesignControls( ditch_type: state.ditchType, paved: state.paved, two_stage_slope: state.twoStage, + ditch_enabled: state.ditchEnabled, }); }; - bar.append( - segment(L("B06_Design_Ground_Legend"), GROUND_OPTIONS, state.ground, (value) => { + // 지반유형: 제목행 배치용으로 분리 반환(D-6). 라벨 삭제(3번) — 버튼만. + const groundSegment = segment( + "", + GROUND_OPTIONS, + state.ground, + (value) => { state.ground = value; emit(); - }), - segment(L("B06_Design_Mode_Legend"), MODE_OPTIONS, state.mode, (value) => { - state.mode = value; - emit(); - }), + }, ); - if (needsDitch()) { - bar.append( - segment(L("B06_Design_Ditch_Legend"), DITCH_OPTIONS, state.ditch, (value) => { + groundSegment.classList.add("b06-design__seg--header"); + // 단면 유형은 지형에서 자동 판정되며(D-2), 제목행 pill로 표시한다(E-3, Cross_View에서 생성). + // 아래 옵션은 상시 노출하되 선행 조건 미충족 시 비활성 처리한다(E-6). + const rockCut = isRock(state.ground) && state.mode !== "both_fill"; + const hasDitch = state.mode !== "both_fill"; + // 측구 생성 여부(자동 판정 or 사용자 override) — 측구형식은 측구가 있을 때만 의미 있다. + const ditchOn = state.ditchEnabled ?? design?.ditch_enabled ?? true; + + // 측구 방향(좌/우): 양절(both_cut)에서만 활성. 편절은 절토측 자동, 양성은 측구 없음. 라벨 삭제(E-7). + bar.append( + segment( + "", + DITCH_OPTIONS, + state.ditch, + (value) => { state.ditch = value; emit(); - }), - ); - } - // 측구형식(일반/L형): 암 지반 + 측구가 존재하는 단면(양성 제외)에서만 노출. - if (isRock(state.ground) && state.mode !== "both_fill") { - bar.append( - segment(L("B06_Design_DitchType_Legend"), DITCH_TYPE_OPTIONS, state.ditchType, (value) => { + }, + state.mode !== "both_cut", + L("B06_Design_Disabled_BothCutOnly"), + ), + ); + // 측구 생성 토글(D-1): 측구 있는 단면에서만 활성. 버튼명 "측구" 고정, 컬러로 상태 표시(E-7). + const ditchToggle = toggle( + "", + ditchOn, + L("B06_Design_Ditch_On"), + L("B06_Design_Ditch_Off"), + () => { + state.ditchEnabled = !ditchOn; + emit(); + }, + !hasDitch, + L("B06_Design_Disabled_NoDitch"), + ); + bar.append(ditchToggle.wrap); + // 측구형식(일반/L형): 암 지반 + 절토 단면 + 측구가 실제 있을 때만 활성. + bar.append( + segment( + L("B06_Design_DitchType_Legend"), + DITCH_TYPE_OPTIONS, + state.ditchType, + (value) => { state.ditchType = value; emit(); - }), - ); - // 2단계 경사 토글: 암 지반 + 절토가 있는 단면(양성 제외)에서만. 기본 활성, 해제 시 단일 암 경사. - const twoStage = toggle( - L("B06_Design_TwoStage_Legend"), - state.twoStage, - L("B06_Design_TwoStage_On"), - L("B06_Design_TwoStage_Off"), - () => { - state.twoStage = !state.twoStage; - emit(); }, - ); - bar.append(twoStage.wrap); - } - // 포장 토글: 지반유형과 중첩 적용(횡단경사·포장층만 변경). + !rockCut || !ditchOn, + !ditchOn ? L("B06_Design_Disabled_NoDitchType") : L("B06_Design_Disabled_RockCut"), + ), + ); + // 2단계 경사 토글: 암 지반 + 절토 단면에서만 활성. 활성="복합경사"/비활성="단경사"(E-7). + const twoStage = toggle( + "", + state.twoStage, + L("B06_Design_TwoStage_On"), + L("B06_Design_TwoStage_Off"), + () => { + state.twoStage = !state.twoStage; + emit(); + }, + !rockCut, + L("B06_Design_Disabled_RockCut"), + ); + bar.append(twoStage.wrap); + // 포장 토글: 라벨 삭제, 버튼명 활성="포장"/비활성="비포장"(컬러 유지) — E-7. const paved = toggle( - L("B06_Design_Paved_Legend"), + "", state.paved, L("B06_Design_Paved_On"), L("B06_Design_Paved_Off"), @@ -281,13 +349,16 @@ export function buildDesignControls( } bar.append(paved.wrap); - // 암 경계선 제어: 암 지반에서만 노출(서버 재계산 없이 세션 보관, 확정 시 DB 병합). - if (rockBoundary && isRock(state.ground)) { - bar.append(rockBoundaryRow(section, rockBoundary)); - } + // 암 경계선 제어는 그래프 X축 제목 행으로 이동(E-7, Cross_View에서 배치). + // 절·성토 면적 readout은 그래프 중상단 오버레이로 이동(E-4, Cross_View에서 배치). + return { bar, groundSegment }; +} + +/** 절·성토 면적값 오버레이(E-4) — 그래프 중상단에 배경색과 함께 표시한다. */ +export function buildAreaReadout(design: CrossDesign | undefined): HTMLElement { const readout = document.createElement("div"); - readout.className = "b06-design__areas"; + readout.className = "b06-cross-card__areas"; if (design) { const cut = document.createElement("span"); cut.className = "b06-design__area b06-design__area--cut"; @@ -302,8 +373,7 @@ export function buildDesignControls( unset.textContent = L("B06_Design_Unset"); readout.append(unset); } - bar.append(readout); - return bar; + return readout; } /** 횡단 SVG에 표준단면 설계선을 겹쳐 그린다. */ @@ -362,9 +432,11 @@ export function appendPavementOverlay( x: (offset: number) => number, toDisplayY: (elevation: number) => number, ): void { - if (!design.paved || !design.road_edges) return; + // 포장은 차도(노견 제외)만 덮는다(D-5). 구 데이터 폴백으로 road_edges를 쓴다. + const edges = design.carriageway_edges ?? design.road_edges; + if (!design.paved || !edges) return; const thickness = design.pavement_thickness_m ?? 0.2; - const { left, right } = design.road_edges; + const { left, right } = edges; const points = [ `${x(left.offset_m)},${toDisplayY(left.elevation_m)}`, `${x(right.offset_m)},${toDisplayY(right.elevation_m)}`, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts index 610a794c..b5379a00 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts @@ -12,7 +12,10 @@ import { appendCrossDesignOverlay, appendPavementOverlay, appendRockBoundaryOverlay, + buildAreaReadout, buildDesignControls, + buildRockBoundaryControl, + sectionModeLabel, type RockBoundaryControl, } from "./B06_wf3_ProfileCross_UI_Cross_Design"; import { @@ -109,6 +112,87 @@ export function crossCardNaturalHeight( ); } +/** + * 카드 SVG에 마우스 휠 줌(커서 중심) + 드래그 팬 + 더블클릭 원복을 붙인다(E-5). + * viewBox만 조작하고 선은 `vector-effect: non-scaling-stroke`(CSS)로 굵기를 유지해 + * 확대해도 선·글자가 선명하다. 최대 8배까지, 축소는 원본까지만 허용한다. + */ +function attachZoomPan(svg: SVGSVGElement, widthPx: number, heightPx: number): void { + const base = { x: 0, y: 0, w: widthPx, h: heightPx }; + const vb = { ...base }; + const applyVB = (): void => svg.setAttribute("viewBox", `${vb.x} ${vb.y} ${vb.w} ${vb.h}`); + const clampPan = (): void => { + vb.x = Math.min(Math.max(vb.x, base.x), base.x + base.w - vb.w); + vb.y = Math.min(Math.max(vb.y, base.y), base.y + base.h - vb.h); + }; + + svg.addEventListener( + "wheel", + (event) => { + event.preventDefault(); + const rect = svg.getBoundingClientRect(); + const mx = vb.x + ((event.clientX - rect.left) / rect.width) * vb.w; + const my = vb.y + ((event.clientY - rect.top) / rect.height) * vb.h; + const factor = event.deltaY < 0 ? 0.85 : 1 / 0.85; + const nw = Math.min(base.w, Math.max(base.w / 8, vb.w * factor)); + const nh = Math.min(base.h, Math.max(base.h / 8, vb.h * factor)); + vb.x = mx - (mx - vb.x) * (nw / vb.w); + vb.y = my - (my - vb.y) * (nh / vb.h); + vb.w = nw; + vb.h = nh; + clampPan(); + applyVB(); + }, + { passive: false }, + ); + + let panning = false; + let moved = false; + let lastX = 0; + let lastY = 0; + svg.addEventListener("pointerdown", (event) => { + panning = true; + moved = false; + lastX = event.clientX; + lastY = event.clientY; + svg.setPointerCapture(event.pointerId); + }); + svg.addEventListener("pointermove", (event) => { + if (!panning) return; + if (Math.abs(event.clientX - lastX) + Math.abs(event.clientY - lastY) > 2) moved = true; + const rect = svg.getBoundingClientRect(); + vb.x -= ((event.clientX - lastX) / rect.width) * vb.w; + vb.y -= ((event.clientY - lastY) / rect.height) * vb.h; + lastX = event.clientX; + lastY = event.clientY; + clampPan(); + applyVB(); + }); + const endPan = (event: PointerEvent): void => { + panning = false; + try { + svg.releasePointerCapture(event.pointerId); + } catch { + /* 이미 해제됨 */ + } + }; + svg.addEventListener("pointerup", endPan); + svg.addEventListener("pointercancel", endPan); + // 드래그(팬)로 끝난 클릭은 카드 선택으로 전파하지 않는다. + svg.addEventListener("click", (event) => { + if (moved) event.stopPropagation(); + }); + // 더블클릭 원복. + svg.addEventListener("dblclick", (event) => { + event.stopPropagation(); + vb.x = base.x; + vb.y = base.y; + vb.w = base.w; + vb.h = base.h; + applyVB(); + }); +} + export function createCrossSectionCard( section: CrossSection, selected: boolean, @@ -131,13 +215,22 @@ export function createCrossSectionCard( if (event.key === "Enter" || event.key === " ") onSelect(section.station_id); }); + // 제목행 1행 구조(E-2/E-3): 측점 위치표기 → 단면유형 pill → 지반유형 → 구조물 → 측점정보. const header = document.createElement("header"); const title = document.createElement("div"); + title.className = "b06-cross-card__title"; const label = document.createElement("strong"); label.textContent = stationLabel(section.chainage_m, stationInterval); const chainage = document.createElement("span"); chainage.textContent = `${section.chainage_m.toFixed(1)}m`; title.append(label, chainage); + + // 단면유형 pill(자동 판정, 읽기 전용) — 구조물 pill과 동일 표기, 좌측 배치(E-3). + const modePill = document.createElement("span"); + modePill.className = "b06-cross-card__mode"; + modePill.textContent = sectionModeLabel(section.design?.section_mode); + modePill.title = L("B06_Design_Mode_Legend"); + const kind = document.createElement("span"); kind.textContent = section.kind === "ep" @@ -145,7 +238,6 @@ export function createCrossSectionCard( : section.kind === "bp" ? L("B06_Profile_View_Kind_BP") : L("B06_Profile_View_Kind_Station"); - // kind 라벨(일반측점/BP/EP) 좌측에 구조물 정보를 표기(비정규 측점이 확정 시 실어온 값). const meta = document.createElement("div"); meta.className = "b06-cross-card__meta"; if (section.structure) { @@ -156,9 +248,21 @@ export function createCrossSectionCard( meta.append(structure); } meta.append(kind); - header.append(title, meta); - card.append(header); - if (onDesignChange) card.append(buildDesignControls(section, onDesignChange, rockBoundary)); + + // 단면유형 pill은 지반유형 우측·우측 맞춤으로 배치(1번). 좌측 구분선은 지반유형 세그먼트에 준다(3번). + modePill.classList.add("b06-cross-card__mode--right"); + header.append(title); + if (onDesignChange) { + // 제목행: 측점 라벨 → (구분선) 지반유형 → 단면유형 pill(우측) → 구조물·kind (D-6/E-2/1·3번). + const controls = buildDesignControls(section, onDesignChange); + controls.groundSegment.classList.add("b06-cross-card__ground"); + header.append(controls.groundSegment, modePill, meta); + card.append(header); + card.append(controls.bar); + } else { + header.append(modePill, meta); + card.append(header); + } const metrics = crossPlotMetrics( section, @@ -332,7 +436,19 @@ export function createCrossSectionCard( class: "b06-chart__axis-label", }), ); - card.append(svg); + // 마우스 휠 줌·드래그 팬·더블클릭 원복(E-5). viewBox 조작 + non-scaling-stroke로 선명도 유지. + const chartWrap = document.createElement("div"); + chartWrap.className = "b06-cross-card__chart-wrap"; + attachZoomPan(svg, widthPx, heightPx); + // 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). + chartWrap.append(svg, buildAreaReadout(section.design)); + // 암 경계선 제어는 그래프 X축 제목 행 우측에 배치(E-7). 암 지반에서만. + if (rockBoundary && section.design?.geometry_preset === "rock") { + const rockControl = buildRockBoundaryControl(section, rockBoundary); + rockControl.classList.add("b06-cross-card__rockb"); + chartWrap.append(rockControl); + } + card.append(chartWrap); } const footer = document.createElement("footer"); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index d0bb6430..74fa64d7 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -37,6 +37,7 @@ import { type StandardPanelController, } from "./B06_wf3_ProfileCross_UI_Standard_Panel"; import "./B06_wf3_ProfileCross_UI_Style.css"; +import "./B06_wf3_ProfileCross_UI_Style_Cross.css"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -162,6 +163,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { ditch_type: design.ditch_type ?? "standard", paved: design.paved, two_stage_slope: design.two_stage_slope ?? true, + ditch_enabled: design.ditch_enabled ?? null, }; } @@ -171,6 +173,23 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { if (change && change.ground_type !== "soil") void handleDesignChange(chainageM, change); } + /** + * 로드 시 2단계 경사 필드(`two_stage_slope`)가 없는 옛 암 측점 design을 최신 엔진으로 + * 자동 재계산한다(E-1). 엔진 업데이트 전 저장된 암 design은 단일 경사로 남아 있어, + * 지반유형을 다시 고르기 전엔 2단계가 반영되지 않던 문제를 해소한다. 세션 암경계 + * 오프셋을 실어 재계산하므로 지반유형 변경과 동일한 결과가 나온다. + */ + function reconcileStaleRockDesigns(): void { + if (!sectionDetail) return; + for (const section of sectionDetail.cross_sections) { + const design = section.design; + if (design?.geometry_preset === "rock" && design.two_stage_slope === undefined) { + const change = changeFromDesign(section.chainage_m); + if (change) void handleDesignChange(section.chainage_m, change); + } + } + } + /* ── 암 경계선 오프셋(측점별) 세션 저장소 ───────────────────────────── * 서버 재계산 없이 프론트 세션(sessionStorage)에 보관하고, 종횡단 확정 시 * cross_patches로 DB(data.design.rock_boundary_offset_m)에 병합한다. @@ -420,6 +439,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { stationInterval = storedOptions.station_interval_m; appliedHalfWidth = crossHalfWidth(); renderSectionDetail(); + reconcileStaleRockDesigns(); // 옛 암 design 2단계 자동 재계산(E-1) updateActionState(); } catch (error) { const detail = error instanceof Error ? ` ${error.message}` : ""; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common.ts index 98379e74..2a8696c4 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common.ts @@ -23,10 +23,11 @@ export const LONG_WIDTH = 1200; export const LONG_HEIGHT = 220; export const CROSS_WIDTH = 560; export const CROSS_HEIGHT = 250; -export const CROSS_GRID_MIN_WIDTH = 480; +// 한 행 맞춤 기준 최소 카드 폭 — 조금 더 넓은 화면 필요(480→560, 약 +17%). +export const CROSS_GRID_MIN_WIDTH = 560; export const CROSS_GRID_GAP = 16; export const LONG_PAD = { left: 62, right: 24, top: 30, bottom: 52 }; -export const CROSS_PAD = { left: 58, right: 20, top: 20, bottom: 52 }; +export const CROSS_PAD = { left: 58, right: 20, top: 10, bottom: 52 }; export interface YScaleOptions { pixelsPerMeter: number; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Diagram.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Diagram.ts index e6f6f2b7..ec69143c 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Diagram.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Diagram.ts @@ -59,32 +59,23 @@ export function buildStandardDiagram(): HTMLElement { ), ); - // 노면(노견 포함): 좌 절토측이 측구 방향으로 살짝 낮게 기운다(횡단경사). + // 노면(노견 포함): 위치 안내 모식도라 횡단경사는 반영하지 않고 수평으로 그린다(D-7). const roadLeft = 95; const roadRight = 205; const shoulderL = 108; // 노견 좌 경계 const shoulderR = 192; // 노견 우 경계 - const roadYL = 128; - const roadYR = 122; + const roadY = 128; svg.append( node( "polyline", - { points: `${roadLeft},${roadYL} ${roadRight},${roadYR}`, fill: "none" }, + { points: `${roadLeft},${roadY} ${roadRight},${roadY}`, fill: "none" }, "b06-diag__road", ), ); // 노견 경계 눈금(좌/우) svg.append( - node( - "line", - { x1: shoulderL, y1: roadYL - 5, x2: shoulderL, y2: roadYL + 5 }, - "b06-diag__tick", - ), - node( - "line", - { x1: shoulderR, y1: roadYR - 5, x2: shoulderR, y2: roadYR + 5 }, - "b06-diag__tick", - ), + node("line", { x1: shoulderL, y1: roadY - 5, x2: shoulderL, y2: roadY + 5 }, "b06-diag__tick"), + node("line", { x1: shoulderR, y1: roadY - 5, x2: shoulderR, y2: roadY + 5 }, "b06-diag__tick"), ); // 중심선(계획고): 노면 중앙 수직 파선. @@ -96,26 +87,20 @@ export function buildStandardDiagram(): HTMLElement { node( "polygon", { - points: `${roadLeft},${roadYL} ${roadLeft - 6},${roadYL + 16} ${roadLeft - 12},${roadYL + 16} ${roadLeft - 15},${roadYL}`, + points: `${roadLeft},${roadY} ${roadLeft - 6},${roadY + 16} ${roadLeft - 12},${roadY + 16} ${roadLeft - 15},${roadY}`, }, "b06-diag__ditch", ), ); // 절토 사면(좌): 측구 바깥에서 원지반까지 상향. - svg.append(node("line", { x1: roadLeft - 15, y1: roadYL, x2: 26, y2: 66 }, "b06-diag__cut")); + svg.append(node("line", { x1: roadLeft - 15, y1: roadY, x2: 26, y2: 66 }, "b06-diag__cut")); // 성토 사면(우): 노면 우끝에서 원지반까지 하향. - svg.append(node("line", { x1: roadRight, y1: roadYR, x2: 278, y2: 210 }, "b06-diag__fill")); + svg.append(node("line", { x1: roadRight, y1: roadY, x2: 278, y2: 210 }, "b06-diag__fill")); - // 횡단경사 라벨·화살표(노면 위, 측구 방향). 노견 좌/우 라벨은 같은 평행선상 좌·우에 둔다. + // 노견 좌/우 라벨은 같은 평행선상 좌·우에 둔다(횡단경사 라벨·화살표는 D-7에서 제거). const labelLineY = 104; svg.append( - node( - "line", - { x1: centerX + 14, y1: labelLineY + 8, x2: centerX - 14, y2: labelLineY + 12 }, - "b06-diag__slope-arrow", - ), - text(centerX, labelLineY, L("B06_Std_Diagram_CrossSlope"), "b06-diag__label"), text(shoulderL - 6, labelLineY, L("B06_Std_Diagram_ShoulderL"), "b06-diag__label-sm", "end"), text(shoulderR + 6, labelLineY, L("B06_Std_Diagram_ShoulderR"), "b06-diag__label-sm", "start"), ); @@ -124,7 +109,7 @@ export function buildStandardDiagram(): HTMLElement { svg.append( text(centerX, 30, L("B06_Std_Diagram_Center"), "b06-diag__label"), text(centerX, 148, L("B06_Std_Diagram_Road"), "b06-diag__label"), - text(roadLeft - 32, roadYL + 32, L("B06_Std_Diagram_Ditch"), "b06-diag__label-sm", "middle"), + text(roadLeft - 32, roadY + 32, L("B06_Std_Diagram_Ditch"), "b06-diag__label-sm", "middle"), text(46, 54, L("B06_Std_Diagram_Cut"), "b06-diag__label-sm", "middle"), text(252, 196, L("B06_Std_Diagram_Fill"), "b06-diag__label-sm", "middle"), ); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css index d42c1dac..f6a7ef4c 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css @@ -196,20 +196,14 @@ stroke-width: 1.5; } -.b06-diag__slope-arrow { - stroke: var(--color-text-secondary); - stroke-width: 1; - marker-end: none; -} - .b06-diag__label { fill: var(--color-text-body); - font-size: 9px; + font-size: 12px; } .b06-diag__label-sm { fill: var(--color-text-secondary); - font-size: 8px; + font-size: 11px; } .b06-std__group { @@ -294,398 +288,3 @@ word-break: break-all; text-align: right; } - -/* --- 종·횡단 도면 --- */ -.b06-profile__main, -.b06-section { - display: flex; - flex-direction: column; - gap: var(--spacing-24); - min-width: 0; -} - -.b06-section { - box-sizing: border-box; - padding-inline: var(--spacing-24); -} - -.b06-section__panel, -.b06-cross-card { - overflow: hidden; - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); -} - -.b06-section__panel { - position: sticky; - z-index: 2; - top: 0; -} - -.b06-section__panel > header, -.b06-cross-card > header, -.b06-cross-card > footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-8); - padding: var(--spacing-8) var(--spacing-16); - background: var(--color-surface); - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.b06-section__panel > header { - border-bottom: 1px solid var(--color-border); -} - -.b06-section__panel > header h3, -.b06-section__heading h3 { - font-size: var(--text-body); -} - -.b06-section__chart-wrap { - width: 100%; - overflow-x: auto; -} - -.b06-section__chart { - display: block; - max-width: none; -} - -.b06-cross-card > .b06-section__chart { - width: 100%; -} - -.b06-section__heading { - display: flex; - align-items: end; - justify-content: space-between; - gap: var(--spacing-16); - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.b06-section__grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(min(480px, 100%), 1fr)); - gap: var(--spacing-16); -} - -.b06-cross-card { - cursor: pointer; - transition: - border-color var(--transition-fast), - box-shadow var(--transition-fast); -} - -/* 마우스 hover 회색 강조는 선택 강조(빨강)보다 하위 — 미선택 카드에만 적용해 - :not(...):hover 특정도가 selected 규칙을 넘어 빨강 테두리를 덮지 않게 한다. */ -.b06-cross-card:not(.b06-cross-card--selected):hover { - border-color: var(--color-text-muted); - box-shadow: var(--shadow-sm); -} - -.b06-cross-card--selected { - border-color: var(--color-danger); - box-shadow: 0 0 0 2px var(--color-danger); -} - -.b06-cross-card > header { - border-bottom: 1px solid var(--color-border); -} - -.b06-cross-card > header div { - display: flex; - align-items: baseline; - gap: var(--spacing-8); -} - -.b06-cross-card > header strong { - color: var(--color-text); - font-size: var(--text-body-sm); -} - -/* 비정규 측점의 구조물 라벨 — kind(일반측점/BP/EP) 좌측에 자수정색 pill로 표기. */ -.b06-cross-card__structure { - padding: 1px 6px; - border-radius: var(--radius-pills); - background: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 16%, transparent); - color: var(--color-royal-amethyst, rgb(109 40 217)); - font-size: var(--text-caption); - white-space: nowrap; -} - -.b06-cross-card > footer { - border-top: 1px solid var(--color-border); -} - -.b06-section__empty { - display: flex; - min-height: 180px; - align-items: center; - justify-content: center; - padding: var(--spacing-24); - border: 1px dashed var(--color-text-muted); - color: var(--color-text-muted); - background: var(--color-surface); -} - -/* SVG 차트 색상은 테마 토큰만 사용한다. */ -.b06-chart__bg { - fill: var(--color-surface-raised); -} - -.b06-chart__grid { - stroke: var(--color-border); - stroke-width: 1; -} - -.b06-chart__axis { - stroke: var(--color-text-secondary); - stroke-width: 1; -} - -.b06-chart__tick, -.b06-chart__station-label, -.b06-chart__axis-label { - fill: var(--color-text-secondary); - font-family: var(--font-body); -} - -.b06-chart__tick, -.b06-chart__station-label { - font-size: 10px; -} - -/* 구조물(비정규) 측점 이름 — 종단면도 측점선 상단 표기. */ -.b06-chart__structure-label { - font-size: 9px; - font-weight: var(--font-weight-medium); - fill: rgb(180 83 9); -} - -.b06-chart__axis-label { - fill: var(--color-text-body); - font-size: var(--text-caption); - font-weight: var(--font-weight-medium); -} - -.b06-chart__profile { - fill: none; - stroke: var(--color-chart-0); - stroke-width: 2.5; -} - -.b06-chart__cross-profile { - fill: none; - stroke: var(--color-chart-1); - stroke-width: 2.4; -} - -/* 종단 계획선(시공계획고): 지반선과 구분되도록 파선 + 강조색 */ -.b06-chart__design-profile { - fill: none; - stroke: var(--color-royal-amethyst); - stroke-width: 2.2; - stroke-dasharray: 8 4; -} - -/* 절토(계획고가 지반고보다 낮음) / 성토 구간 음영 */ -.b06-chart__band { - stroke: none; -} - -.b06-chart__band--cut { - fill: rgb(220 38 38 / 14%); -} - -.b06-chart__band--fill { - fill: rgb(37 99 235 / 14%); -} - -.b06-chart__balance-boundary { - stroke: var(--color-royal-amethyst); - stroke-width: 1.2; - stroke-dasharray: 3 3; - opacity: 0.55; -} - -.b06-chart__station { - cursor: pointer; -} - -.b06-chart__station-line { - stroke-width: 1.2; -} - -.b06-chart__station-hit { - stroke: transparent; - stroke-width: 14; - pointer-events: stroke; -} - -.b06-chart__station-line--bp, -.b06-chart__station-line--regular { - stroke: var(--color-warning); -} - -.b06-chart__station-line--ep { - stroke: var(--color-accent); - stroke-dasharray: 4 3; -} - -.b06-chart__station-line--selected, -.b06-chart__center-marker { - stroke: var(--color-danger); - stroke-width: 2.5; -} - -/* 십자선이 계획 노선(계획고) 위치를 가리킬 때: 종단 계획선과 동일 색으로 대응시킨다 */ -.b06-chart__center-marker--design { - stroke: var(--color-royal-amethyst); -} - -.b06-chart__station--selected .b06-chart__station-label { - fill: var(--color-danger); - font-weight: var(--font-weight-bold); -} - -/* 측점 표준횡단 설계 지정 컨트롤 (카드 헤더 아래) */ -.b06-design { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8) var(--spacing-16); - padding: var(--spacing-8) 0; - border-bottom: 1px solid var(--color-border); - margin-bottom: var(--spacing-8); -} - -.b06-design__seg { - display: flex; - align-items: center; - gap: var(--spacing-8); -} - -.b06-design__seg-legend { - font-size: 0.72rem; - color: var(--color-text-secondary); - white-space: nowrap; -} - -.b06-design__seg-buttons { - display: inline-flex; - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - overflow: hidden; -} - -.b06-design__btn { - padding: 3px 8px; - font-size: 0.72rem; - color: var(--color-text-secondary); - background: var(--color-surface); - border: none; - border-left: 1px solid var(--color-border); - cursor: pointer; -} - -.b06-design__btn:first-child { - border-left: none; -} - -.b06-design__btn--active { - color: var(--color-surface); - background: var(--color-royal-amethyst); - font-weight: var(--font-weight-medium); -} - -.b06-design__areas { - display: inline-flex; - gap: var(--spacing-8); - margin-left: auto; - font-size: 0.72rem; - font-family: var(--font-mono); -} - -.b06-design__area--cut { - color: rgb(220 38 38); -} - -.b06-design__area--fill { - color: rgb(37 99 235); -} - -.b06-design__area--unset { - color: var(--color-text-muted); -} - -/* 횡단 표준단면 설계선 오버레이 */ -.b06-chart__design-cross { - fill: none; - stroke: var(--color-royal-amethyst); - stroke-width: 1.8; - stroke-linejoin: round; -} - -/* 암 경계선(설계선 복사 + 오프셋): 리핑암·발파암 구간 점선 */ -.b06-chart__rock-boundary { - fill: none; - stroke: var(--color-warning); - stroke-width: 1.6; - stroke-dasharray: 6 4; - stroke-linejoin: round; - opacity: 0.9; -} - -/* 포장층 박스: 노면 양 끝점 기준 두께만큼 하향 채움 */ -.b06-chart__pavement { - fill: color-mix(in srgb, var(--color-text-secondary) 30%, transparent); - stroke: var(--color-text-secondary); - stroke-width: 1; -} - -/* 암 경계선 상/하/리셋 제어 (B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용) */ -.b06-design__rockb-btn { - width: 22px; - height: 22px; - padding: 0; - font-size: 0.7rem; - line-height: 1; - color: var(--color-text-secondary); - background: var(--color-surface); - border: none; - border-left: 1px solid var(--color-border); - cursor: pointer; -} - -.b06-design__rockb-btn:first-child { - border-left: none; -} - -.b06-design__rockb-btn:hover { - color: var(--color-text); - background: var(--color-surface-raised); -} - -.b06-design__rockb-btn.is-reset { - color: var(--color-warning); -} - -.b06-design__rockb-readout { - padding: 0 var(--spacing-8); - font-size: 0.72rem; - font-family: var(--font-mono); - color: var(--color-warning); - align-self: center; -} - -/* 포장 제안 배지: B05 법정 경사 분석이 포장을 권장한 측점 표시 */ -.b06-design__paved-badge { - font-size: 0.72rem; - color: var(--color-warning); - cursor: help; -} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css new file mode 100644 index 00000000..fcc1c4eb --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css @@ -0,0 +1,495 @@ +/* ============================================================================= + * B06_wf3_ProfileCross_UI_Style_Cross.css + * 종·횡단 도면 뷰(섹션·카드·차트·설계 컨트롤·줌/팬·오버레이) 스타일. + * 700줄 제한 대응으로 _UI_Style.css에서 분리(패널/폼/모식도는 원본 유지). + * ========================================================================== */ + +/* --- 종·횡단 도면 --- */ +.b06-profile__main, +.b06-section { + display: flex; + flex-direction: column; + gap: var(--spacing-24); + min-width: 0; +} + +.b06-section { + box-sizing: border-box; + padding-inline: var(--spacing-24); +} + +.b06-section__panel, +.b06-cross-card { + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); +} + +.b06-section__panel { + position: sticky; + z-index: 2; + top: 0; +} + +.b06-section__panel > header, +.b06-cross-card > header, +.b06-cross-card > footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-16); + background: var(--color-surface); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b06-section__panel > header { + border-bottom: 1px solid var(--color-border); +} + +.b06-section__panel > header h3, +.b06-section__heading h3 { + font-size: var(--text-body); +} + +.b06-section__chart-wrap { + width: 100%; + overflow-x: auto; +} + +.b06-section__chart { + display: block; + max-width: none; +} + +/* 횡단도 줌·팬 영역(E-5) — 휠 줌/드래그 팬. */ +.b06-cross-card__chart-wrap { + position: relative; +} + +.b06-cross-card__chart-wrap .b06-section__chart { + cursor: grab; + touch-action: none; +} + +.b06-cross-card__chart-wrap .b06-section__chart:active { + cursor: grabbing; +} + +/* 확대해도 선 굵기 고정(E-5) — 줌 시 해상도 저하처럼 보이던 문제 해소. */ +.b06-section__chart line, +.b06-section__chart polyline, +.b06-section__chart polygon, +.b06-section__chart rect { + vector-effect: non-scaling-stroke; +} + +.b06-cross-card > .b06-section__chart, +.b06-cross-card__chart-wrap > .b06-section__chart { + width: 100%; +} + +.b06-section__heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: var(--spacing-16); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b06-section__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(480px, 100%), 1fr)); + gap: var(--spacing-16); +} + +.b06-cross-card { + cursor: pointer; + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast); +} + +/* 마우스 hover 회색 강조는 선택 강조(빨강)보다 하위 — 미선택 카드에만 적용해 + :not(...):hover 특정도가 selected 규칙을 넘어 빨강 테두리를 덮지 않게 한다. */ +.b06-cross-card:not(.b06-cross-card--selected):hover { + border-color: var(--color-text-muted); + box-shadow: var(--shadow-sm); +} + +.b06-cross-card--selected { + border-color: var(--color-danger); + box-shadow: 0 0 0 2px var(--color-danger); +} + +/* 제목행 1행 구조(E-2/E-3): 측점 라벨 → 단면유형 pill → 지반유형 → 구조물·kind. */ +.b06-cross-card > header { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: var(--spacing-8); + border-bottom: 1px solid var(--color-border); + overflow-x: auto; +} + +.b06-cross-card__title, +.b06-cross-card > header > .b06-cross-card__meta { + display: flex; + align-items: baseline; + gap: var(--spacing-8); + flex: 0 0 auto; +} + +/* 지반유형 세그먼트(D-6): 제목행 1행 내 인라인 배치. 좌측 구분선(3번). */ +.b06-design__seg--header { + flex: 0 0 auto; + justify-content: flex-start; +} + +.b06-cross-card__ground { + padding-left: var(--spacing-8); + border-left: 1px solid var(--color-border); +} + +/* 단면유형 pill(E-3) — 구조물 pill과 동일 표기. */ +.b06-cross-card__mode { + flex: 0 0 auto; + padding: 1px 6px; + border-radius: var(--radius-pills); + background: color-mix(in srgb, var(--color-info, rgb(37 99 235)) 14%, transparent); + color: var(--color-info, rgb(37 99 235)); + font-size: var(--text-caption); + white-space: nowrap; +} + +/* 단면유형 pill을 지반유형 우측·우측 맞춤으로 밀어 붙인다(1번). meta는 그 뒤에 따라온다. */ +.b06-cross-card__mode--right { + margin-left: auto; +} + +.b06-cross-card > header strong { + color: var(--color-text); + font-size: var(--text-body-sm); +} + +/* 비정규 측점의 구조물 라벨 — kind(일반측점/BP/EP) 좌측에 자수정색 pill로 표기. */ +.b06-cross-card__structure { + padding: 1px 6px; + border-radius: var(--radius-pills); + background: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 16%, transparent); + color: var(--color-royal-amethyst, rgb(109 40 217)); + font-size: var(--text-caption); + white-space: nowrap; +} + +.b06-cross-card > footer { + border-top: 1px solid var(--color-border); +} + +.b06-section__empty { + display: flex; + min-height: 180px; + align-items: center; + justify-content: center; + padding: var(--spacing-24); + border: 1px dashed var(--color-text-muted); + color: var(--color-text-muted); + background: var(--color-surface); +} + +/* SVG 차트 색상은 테마 토큰만 사용한다. */ +.b06-chart__bg { + fill: var(--color-surface-raised); +} + +.b06-chart__grid { + stroke: var(--color-border); + stroke-width: 1; +} + +.b06-chart__axis { + stroke: var(--color-text-secondary); + stroke-width: 1; +} + +.b06-chart__tick, +.b06-chart__station-label, +.b06-chart__axis-label { + fill: var(--color-text-secondary); + font-family: var(--font-body); +} + +.b06-chart__tick, +.b06-chart__station-label { + font-size: 10px; +} + +/* 구조물(비정규) 측점 이름 — 종단면도 측점선 상단 표기. */ +.b06-chart__structure-label { + font-size: 9px; + font-weight: var(--font-weight-medium); + fill: rgb(180 83 9); +} + +.b06-chart__axis-label { + fill: var(--color-text-body); + font-size: var(--text-caption); + font-weight: var(--font-weight-medium); +} + +.b06-chart__profile { + fill: none; + stroke: var(--color-chart-0); + stroke-width: 2.5; +} + +.b06-chart__cross-profile { + fill: none; + stroke: var(--color-chart-1); + stroke-width: 2.4; +} + +/* 종단 계획선(시공계획고): 지반선과 구분되도록 파선 + 강조색 */ +.b06-chart__design-profile { + fill: none; + stroke: var(--color-royal-amethyst); + stroke-width: 2.2; + stroke-dasharray: 8 4; +} + +/* 절토(계획고가 지반고보다 낮음) / 성토 구간 음영 */ +.b06-chart__band { + stroke: none; +} + +.b06-chart__band--cut { + fill: rgb(220 38 38 / 14%); +} + +.b06-chart__band--fill { + fill: rgb(37 99 235 / 14%); +} + +.b06-chart__balance-boundary { + stroke: var(--color-royal-amethyst); + stroke-width: 1.2; + stroke-dasharray: 3 3; + opacity: 0.55; +} + +.b06-chart__station { + cursor: pointer; +} + +.b06-chart__station-line { + stroke-width: 1.2; +} + +.b06-chart__station-hit { + stroke: transparent; + stroke-width: 14; + pointer-events: stroke; +} + +.b06-chart__station-line--bp, +.b06-chart__station-line--regular { + stroke: var(--color-warning); +} + +.b06-chart__station-line--ep { + stroke: var(--color-accent); + stroke-dasharray: 4 3; +} + +.b06-chart__station-line--selected, +.b06-chart__center-marker { + stroke: var(--color-danger); + stroke-width: 2.5; +} + +/* 십자선이 계획 노선(계획고) 위치를 가리킬 때: 종단 계획선과 동일 색으로 대응시킨다 */ +.b06-chart__center-marker--design { + stroke: var(--color-royal-amethyst); +} + +.b06-chart__station--selected .b06-chart__station-label { + fill: var(--color-danger); + font-weight: var(--font-weight-bold); +} + +/* 측점 표준횡단 설계 지정 컨트롤 (카드 헤더 아래) — 좌측 여유 추가(2번). */ +.b06-design { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8) var(--spacing-16); + padding: var(--spacing-8) 0 var(--spacing-8) var(--spacing-16); + border-bottom: 1px solid var(--color-border); + margin-bottom: var(--spacing-8); +} + +.b06-design__seg { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + + +.b06-design__seg-legend { + font-size: 0.72rem; + color: var(--color-text-secondary); + white-space: nowrap; +} + +/* 선행 조건 미충족 옵션(E-6) — 상시 노출하되 흐리게·클릭 불가. */ +.b06-design__seg--disabled { + opacity: 0.4; +} + +.b06-design__seg--disabled .b06-design__btn { + cursor: not-allowed; +} + +.b06-design__seg-buttons { + display: inline-flex; + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + overflow: hidden; +} + +.b06-design__btn { + padding: 3px 8px; + font-size: 0.72rem; + color: var(--color-text-secondary); + background: var(--color-surface); + border: none; + border-left: 1px solid var(--color-border); + cursor: pointer; +} + +.b06-design__btn:first-child { + border-left: none; +} + +.b06-design__btn--active { + color: var(--color-surface); + background: var(--color-royal-amethyst); + font-weight: var(--font-weight-medium); +} + +.b06-design__areas { + display: inline-flex; + gap: var(--spacing-8); + margin-left: auto; + font-size: 0.72rem; + font-family: var(--font-mono); +} + +/* 암 경계선 제어(E-7) — 그래프 X축 제목 행 우측 맞춤. */ +.b06-cross-card__rockb { + position: absolute; + right: var(--spacing-8); + bottom: 2px; + background: color-mix(in srgb, var(--color-surface) 82%, transparent); + border-radius: var(--radius-inputs); + padding: 1px 4px; +} + +/* 절·성토 면적 오버레이(E-4) — 그래프 중상단, 배경색으로 그래프 선과 겹쳐도 가독. */ +.b06-cross-card__areas { + position: absolute; + top: var(--spacing-8); + left: 50%; + transform: translateX(-50%); + display: inline-flex; + gap: var(--spacing-8); + padding: 2px 8px; + border-radius: var(--radius-inputs); + background: color-mix(in srgb, var(--color-surface) 82%, transparent); + font-size: 0.72rem; + font-family: var(--font-mono); + white-space: nowrap; + pointer-events: none; +} + +.b06-design__area--cut { + color: rgb(220 38 38); +} + +.b06-design__area--fill { + color: rgb(37 99 235); +} + +.b06-design__area--unset { + color: var(--color-text-muted); +} + +/* 횡단 표준단면 설계선 오버레이 */ +.b06-chart__design-cross { + fill: none; + stroke: var(--color-royal-amethyst); + stroke-width: 1.8; + stroke-linejoin: round; +} + +/* 암 경계선(설계선 복사 + 오프셋): 리핑암·발파암 구간 점선 */ +.b06-chart__rock-boundary { + fill: none; + stroke: var(--color-warning); + stroke-width: 1.6; + stroke-dasharray: 6 4; + stroke-linejoin: round; + opacity: 0.9; +} + +/* 포장층 박스: 노면 양 끝점 기준 두께만큼 하향 채움 */ +.b06-chart__pavement { + fill: color-mix(in srgb, var(--color-text-secondary) 30%, transparent); + stroke: var(--color-text-secondary); + stroke-width: 1; +} + +/* 암 경계선 상/하/리셋 제어 (B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용) */ +.b06-design__rockb-btn { + width: 22px; + height: 22px; + padding: 0; + font-size: 0.7rem; + line-height: 1; + color: var(--color-text-secondary); + background: var(--color-surface); + border: none; + border-left: 1px solid var(--color-border); + cursor: pointer; +} + +.b06-design__rockb-btn:first-child { + border-left: none; +} + +.b06-design__rockb-btn:hover { + color: var(--color-text); + background: var(--color-surface-raised); +} + +.b06-design__rockb-btn.is-reset { + color: var(--color-warning); +} + +.b06-design__rockb-readout { + padding: 0 var(--spacing-8); + font-size: 0.72rem; + font-family: var(--font-mono); + color: var(--color-warning); + align-self: center; +} + +/* 포장 제안 배지: B05 법정 경사 분석이 포장을 권장한 측점 표시 */ +.b06-design__paved-badge { + font-size: 0.72rem; + color: var(--color-warning); + cursor: help; +} diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 3e0dfc1a..1e0032a5 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -785,6 +785,9 @@ export const ui_locales = { B06_Profile_View_ElevationAxis: ["지반고 (m)", "Elevation (m)"], B06_Profile_View_CenterElevation: ["중심고", "Center elevation"], B06_Profile_View_Azimuth: ["방위각", "Azimuth"], + B06_Profile_Zoom_In: ["확대", "Zoom in"], + B06_Profile_Zoom_Out: ["축소", "Zoom out"], + B06_Profile_Zoom_Reset: ["원래 크기", "Reset zoom"], B06_Profile_View_Kind_BP: ["BP", "BP"], B06_Profile_View_Kind_EP: ["EP", "EP"], B06_Profile_View_Kind_Station: ["일반 측점", "Station"], @@ -820,8 +823,27 @@ export const ui_locales = { B06_Design_Paved_On: ["포장", "Paved"], B06_Design_Paved_Off: ["비포장", "Unpaved"], B06_Design_TwoStage_Legend: ["2단계 경사", "Two-stage slope"], - B06_Design_TwoStage_On: ["적용", "On"], - B06_Design_TwoStage_Off: ["단일 경사", "Single slope"], + B06_Design_TwoStage_On: ["복합경사", "Compound slope"], + B06_Design_TwoStage_Off: ["단경사", "Single slope"], + B06_Design_Ditch_Legend2: ["측구", "Ditch"], + B06_Design_Ditch_On: ["측구", "Ditch"], + B06_Design_Ditch_Off: ["측구", "Ditch"], + B06_Design_Disabled_BothCutOnly: [ + "양절 단면에서만 선택할 수 있습니다.", + "Available only for both-cut sections.", + ], + B06_Design_Disabled_RockCut: [ + "암(리핑/발파) 지반의 절토 단면에서만 사용할 수 있습니다.", + "Available only for rock ground with a cut section.", + ], + B06_Design_Disabled_NoDitch: [ + "측구가 있는 단면(양성 제외)에서만 사용할 수 있습니다.", + "Available only for sections with a ditch (not both-fill).", + ], + B06_Design_Disabled_NoDitchType: [ + "측구를 생성한 경우에만 형식을 선택할 수 있습니다.", + "Available only when a ditch is created.", + ], B06_Design_Paved_Suggested: [ "종단경사 법정 상한 초과 — 포장 권장 (임도설치 및 관리 등에 관한 규정 별표 1-2)", "Grade exceeds legal limit — pavement recommended (Forest Road Regulation, Annex 1-2)",