diff --git a/B06_Section/B06_Section_UI_Cross_Fit.ts b/B06_Section/B06_Section_UI_Cross_Fit.ts new file mode 100644 index 00000000..9751ed74 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Fit.ts @@ -0,0 +1,74 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Fit.ts + * 횡단 카드 「교차점 맞춤」 — 절토·성토 사면이 원지반과 만나는 지점(교차점)이 모두 + * 보이는 표시 반폭(m)을 구한다. + * + * 보유 샘플(계산 반폭, 기본 20m) 안에서 교차점을 찾으면 그 자리 + 여유로 끝난다. + * 샘플 끝까지 만나지 않으면 **추세로 외삽**한다 — 끝단의 계획 사면 기울기와 지반 + * 기울기 차이로 만나는 자리를 추정하고 +1m 해서 넘긴다(2026-08-23 사용자 지시). + * 그 값이 계산 반폭을 넘으면 페이지가 백엔드 재생성으로 샘플을 넓힌다. + * ========================================================================== */ + +import type { CrossSection } from "./B06_Section_Api_Fetch"; +import { + designInterpolator, + groundInterpolator, + slopeToeOffset, +} from "./B06_Section_UI_Cross_Culvert_Solve"; + +/** 교차점이 샘플 끝에 걸렸다고 볼 여유(m) — 끝 격자에서 잡히면 실제로는 더 밖이다. */ +const EDGE_TOLERANCE_M = 0.05; +/** 추세 기울기를 재는 구간 길이(m). */ +const TREND_SPAN_M = 2; +/** 외삽 결과의 상한(m) — 발산하는 지형에서 폭이 무한정 커지는 것을 막는다. */ +const MAX_FIT_HALF_WIDTH_M = 60; + +/** + * 절·성토 교차점이 모두 들어오는 표시 반폭(m). 설계선·지반선이 없으면 null. + * + * 반환값은 1m 단위 올림 — 교차점이 축선에 딱 붙지 않게 한다. + */ +export function toeFitHalfWidth(section: CrossSection): number | null { + const design = section.design; + if (!design) return null; + const groundAt = groundInterpolator(section.samples); + const designAt = designInterpolator(design.design_line); + const edges = design.road_edges; + if (!groundAt || !designAt || !edges) return null; + const offsets = section.samples.map((sample) => sample.offset_m ?? 0); + const limits = { left: Math.max(...offsets), right: Math.min(...offsets) }; + let extent = 0; + for (const side of ["left", "right"] as const) { + const edge = side === "left" ? edges.left : edges.right; + const limit = limits[side]; + const toe = slopeToeOffset(designAt, groundAt, edge.offset_m, limit); + // 샘플 끝에서 멈췄고 아직 지반과 만나지 않았으면 추세로 더 밖을 추정한다. + const stopped = Math.abs(toe - limit) <= EDGE_TOLERANCE_M; + const reach = stopped ? extrapolateMeet(designAt, groundAt, limit, edge.offset_m) : toe; + extent = Math.max(extent, Math.abs(reach)); + } + if (!(extent > 0)) return null; + return Math.min(Math.ceil(extent + 1), MAX_FIT_HALF_WIDTH_M); +} + +/** + * 샘플 끝(limit)에서 계획 사면과 지반의 기울기 차로 만나는 자리를 외삽한다. + * 벌어지기만 하면(차이가 줄지 않으면) 끝 자리를 그대로 돌려준다 — 무한정 넓히지 않는다. + */ +function extrapolateMeet( + designAt: (offset: number) => number, + groundAt: (offset: number) => number, + limit: number, + startOffset: number, +): number { + const outward = Math.sign(limit - startOffset) || 1; + const back = limit - outward * TREND_SPAN_M; + const diffAtLimit = designAt(limit) - groundAt(limit); + const diffAtBack = designAt(back) - groundAt(back); + // 바깥으로 갈수록 차이가 줄어드는 속도(m/m). 0 이하이면 만나지 않는다. + const closingRate = (Math.abs(diffAtBack) - Math.abs(diffAtLimit)) / TREND_SPAN_M; + if (!(closingRate > 1e-6)) return limit; + const extra = Math.abs(diffAtLimit) / closingRate; + if (!Number.isFinite(extra)) return limit; + return limit + outward * Math.min(extra, MAX_FIT_HALF_WIDTH_M); +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 84b15c98..fc749b46 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -33,7 +33,8 @@ import type { } from "./B06_Section_UI_Cross_Culvert_Wire"; import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel"; import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; -import type { ZoomPanState } from "./B06_Section_UI_Cross_View_Zoom"; +import type { CrossWidthActions, ZoomPanState } from "./B06_Section_UI_Cross_View_Zoom"; +import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert"; import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; @@ -76,8 +77,11 @@ export interface CrossCardElement extends HTMLElement { export interface StationWidthControl { /** 개별값(세션→저장값) 우선. 없으면 undefined — 카드가 전역 반폭으로 그린다. */ widthFor: (section: CrossSection) => number | undefined; - adjust: (chainageM: number, deltaM: number) => void; - reset: (chainageM: number) => void; + /** + * 이 측점 표시 반폭을 지정한다(2026-08-23 개편). 계산 반폭(기본 20m)을 넘는 값이면 + * 페이지가 백엔드 재생성으로 샘플을 넓힌 뒤 이 카드만 그 폭으로 다시 그린다. + */ + request: (chainageM: number, widthM: number) => void; } // 기슭막이·유입 구조물·다단 제어 인터페이스는 Wire에 있다(700줄 제한) — 재수출. @@ -106,7 +110,7 @@ export function createCrossSectionCard( initialAreaKey?: CrossAreaKey | null, /** 면적 값을 눌렀을 때. 선택되지 않은 카드에서도 눌릴 수 있어 측점 id를 함께 넘긴다. */ onAreaSelect?: (stationId: string, key: CrossAreaKey | null) => void, - /** 개별 표시 반폭 제어 — 있으면 카드 하단에 ◀/▶/↺ 버튼 그룹을 우측 맞춤으로 단다. */ + /** 개별 표시 반폭 제어 — 있으면 우측 상단 줌 버튼이 원배율에서 반폭을 조절한다. */ stationWidth?: StationWidthControl, /** 기슭막이 X 자리 제어 — 있으면 벽이 선택 가능해지고 선택 시 ◀/▶/↺가 뜬다. */ revetOffset?: RevetOffsetControl, @@ -628,7 +632,19 @@ export function createCrossSectionCard( }); showRevetControl = (visible) => panel.show(visible ? activeRevet : null); showRevetControl(activeRevet !== null); - chartWrap.append(svg, readout.root, buildZoomControls(zoomPan), panel.root); + // 우측 상단 줌 버튼이 원배율에서 표시 반폭까지 다룬다(하단 ◀/▶/↺ 폐지, 2026-08-23). + const widthActions: CrossWidthActions | undefined = stationWidth && { + step: (deltaM) => + stationWidth.request( + section.chainage_m, + Math.round((effectiveHalfWidth ?? maxOffset) + deltaM), + ), + fit: () => { + const fitted = toeFitHalfWidth(section); + if (fitted !== null) stationWidth.request(section.chainage_m, fitted); + }, + }; + chartWrap.append(svg, readout.root, buildZoomControls(zoomPan, widthActions), panel.root); if (activeArea) { setBandActive(activeArea); setChipActive(activeArea); @@ -646,30 +662,6 @@ export function createCrossSectionCard( rockControl.classList.add("b06-cross-card__rockb"); footer.append(rockControl); } - // 개별 표시 반폭 ◀/▶/↺ — 우측 맞춤, 초기화는 전역 반폭 복귀(2026-08-06). - if (stationWidth) { - const widthControl = document.createElement("div"); - widthControl.className = "b06-cross-card__widthctl"; - const makeButton = (label: string, title: string, onClick: () => void): HTMLButtonElement => { - const button = document.createElement("button"); - button.type = "button"; - button.className = "b06-design__rockb-btn"; - button.textContent = label; - button.title = title; - button.addEventListener("click", (event) => { - // 카드 선택 클릭으로 번지면 재렌더로 줌·팬이 초기화된다. - event.stopPropagation(); - onClick(); - }); - return button; - }; - widthControl.append( - makeButton("◀", L("B06_Cross_Width_Dec"), () => stationWidth.adjust(section.chainage_m, -1)), - makeButton("▶", L("B06_Cross_Width_Inc"), () => stationWidth.adjust(section.chainage_m, 1)), - makeButton("↺", L("B06_Cross_Width_Reset"), () => stationWidth.reset(section.chainage_m)), - ); - footer.append(widthControl); - } card.append(footer); return card; } diff --git a/B06_Section/B06_Section_UI_Cross_View_Zoom.ts b/B06_Section/B06_Section_UI_Cross_View_Zoom.ts index fddbc8c0..a16d2665 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Zoom.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Zoom.ts @@ -16,6 +16,19 @@ export interface ZoomPanHandle { /** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */ zoom: (factor: number) => void; reset: () => void; + /** 현재 배율 — 원배율(1)일 때는 버튼이 배율 대신 **표시 반폭**을 조절한다. */ + scale: () => number; +} + +/** + * 카드 표시 반폭 조작(2026-08-23 사용자 지시). 카드 하단 ◀/▶/↺을 없애고 우측 상단 + * 줌 버튼에 합쳤다 — 원배율에서 −는 폭 넓히기, +는 폭 좁히기, ⤢는 교차점 맞춤이다. + */ +export interface CrossWidthActions { + /** 표시 반폭을 deltaM(m)만큼 조절한다. 계산 반폭을 넘으면 페이지가 재생성까지 처리. */ + step: (deltaM: number) => void; + /** 절·성토 교차점이 모두 보이는 폭으로 맞춘다. */ + fit: () => void; } /** 줌·팬 상태 — 카드가 다시 그려질 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */ @@ -123,6 +136,7 @@ export function attachZoomPan( svg.addEventListener("click", (event) => { if (moved) event.stopPropagation(); }); + const currentScale = (): number => scale; const reset = (): void => { scale = 1; tx = 0; @@ -134,11 +148,18 @@ export function attachZoomPan( event.stopPropagation(); reset(); }); - return { zoom, reset }; + return { zoom, reset, scale: currentScale }; } -/** 그래프 우측 상단 줌 버튼(확대/축소/핏). 휠을 대신하는 조작구다. */ -export function buildZoomControls(handle: ZoomPanHandle): HTMLElement { +/** + * 그래프 우측 상단 줌 버튼(확대/축소/핏). 휠을 대신하는 조작구다. + * + * `width`를 넘기면 **원배율에서만** 버튼이 표시 반폭을 조절한다(2026-08-23 사용자 지시): + * − : 배율>1이면 축소, 원배율이면 반폭 +1m(계산 반폭 20m를 넘으면 백엔드 재계산) + * + : 배율>1이면 확대, 원배율이면 반폭 −1m + * ⤢ : 배율 원복 + 절·성토 교차점이 다 보이는 폭으로 맞춤 + */ +export function buildZoomControls(handle: ZoomPanHandle, width?: CrossWidthActions): HTMLElement { const bar = document.createElement("div"); bar.className = "b06-cross-card__zoom"; const add = (label: string, title: string, action: () => void): void => { @@ -154,8 +175,17 @@ export function buildZoomControls(handle: ZoomPanHandle): HTMLElement { }); bar.append(button); }; - add("+", L("B06_Profile_View_ZoomIn"), () => handle.zoom(1 / 0.85)); - add("−", L("B06_Profile_View_ZoomOut"), () => handle.zoom(0.85)); - add("⤢", L("B06_Profile_View_ZoomFit"), () => handle.reset()); + add("+", L("B06_Profile_View_ZoomIn"), () => { + if (!width || handle.scale() > 1 + 1e-6) handle.zoom(1 / 0.85); + else width.step(-1); + }); + add("−", L("B06_Profile_View_ZoomOut"), () => { + if (!width || handle.scale() > 1 + 1e-6) handle.zoom(0.85); + else width.step(1); + }); + add("⤢", L("B06_Profile_View_ZoomFit"), () => { + handle.reset(); + width?.fit(); + }); return bar; } diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index e53e5b9f..899df2bc 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -23,10 +23,8 @@ import { saveSections, type CrossSectionPatch, fetchSectionContext, - fetchSectionDetail, getSections, previewCrossDesigns, - regenerateSections, type SectionContextResponse, type SectionDetailResponse, type StandardCrossSection, @@ -47,8 +45,8 @@ import "./B06_Section_UI_Style.css"; import "./B06_Section_UI_Style_Cross.css"; import "./B06_Section_UI_Style_Cross_Controls.css"; import "./B06_Section_UI_Style_Cross_Areas.css"; -import { loadSectionDetail, replaceSectionDetail } from "./B06_Section_Section_Store"; -import { buildGroup, L } from "./B06_Section_UI_Page_Common"; +import { loadSectionDetail } from "./B06_Section_Section_Store"; +import { buildGroup, createSampleWidener, L } from "./B06_Section_UI_Page_Common"; import "@util/common_util_mass_haul.css"; export async function renderB06ProfileCross(root: HTMLElement): Promise { @@ -257,35 +255,23 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } } + const ensureSampledWidth = createSampleWidener({ + target: () => + projectId && currentRouteId !== null ? { projectId, routeId: currentRouteId } : null, + sampledHalfWidth, + applyDetail: (fresh) => { + sectionDetail = fresh; + renderSectionDetail(); + }, + }); + /** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다. * handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만 - * 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. - * - * 횡단 반폭 적용도 여기서 한다(2026-08-06 사용자 지시 — 재계산 버튼 폐지): - * 요청 반폭 ≤ 보유 샘플 폭 → 표시 범위만 바뀌므로 재렌더로 끝(재계산 없음). - * 요청 반폭 > 보유 샘플 폭 → B05부터 재생성해 영구 저장 후 상세를 다시 로드한다 - * (기본 설계는 상세 조회가 자동으로 얹는다. 느려도 허용 — 2026-08-06 사용자 확정). */ + * 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */ async function applyPanelToAll(): Promise { if (!sectionDetail || !projectId || currentRouteId === null) return; const requested = crossHalfWidth(); - if (requested !== undefined && requested > sampledHalfWidth() + 1e-6) { - showLoadingOverlay(); - try { - await regenerateSections(projectId, currentRouteId, requested); - // 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다. - const fresh = await fetchSectionDetail(projectId, currentRouteId); - sectionDetail = fresh; - // 공유 캐시도 새 상세로 바꿔 B05가 옛 값을 못 보게 한다. - replaceSectionDetail(projectId, currentRouteId, fresh); - showToast(L("B06_Profile_Regenerate_Success"), "success"); - } catch (error) { - const detail = error instanceof Error ? ` ${error.message}` : ""; - showToast(`${L("B06_Profile_Regenerate_Failed")}${detail}`, "error"); - hideLoadingOverlay(); - return; - } - hideLoadingOverlay(); - } + if (requested !== undefined && !(await ensureSampledWidth(requested))) return; // 전체 반영 = 개별 반폭도 전역값으로 초기화(2026-08-06). 저장된 개별값 // (design.display_half_width_m)이 남아 있으면 전역 반폭이 무시되므로, 세션에 // 전역값을 명시해 저장값보다 우선하게 한다(확정 시 저장값도 전역으로 덮인다). @@ -392,6 +378,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { detail: () => sectionDetail, crossHalfWidth, sampledHalfWidth, + ensureSampledWidth, }); const stationWidthControl = stationControls.stationWidth; const revetOffsetControl = stationControls.revetOffset; diff --git a/B06_Section/B06_Section_UI_Page_Common.ts b/B06_Section/B06_Section_UI_Page_Common.ts index ef240f38..e8956f76 100644 --- a/B06_Section/B06_Section_UI_Page_Common.ts +++ b/B06_Section/B06_Section_UI_Page_Common.ts @@ -1,4 +1,11 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; +import { + fetchSectionDetail, + regenerateSections, + type SectionDetailResponse, +} from "./B06_Section_Api_Fetch"; +import { replaceSectionDetail } from "./B06_Section_Section_Store"; export function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -13,3 +20,48 @@ export function buildGroup(legend: string, collapsed = false): HTMLElement { group.append(title); return group; } + +/** 반폭 확대(백엔드 재생성)에 필요한 페이지 상태 — 클로저 대신 함수로 받아 결합을 끊는다. */ +export interface SampleWidenerDeps { + /** 현재 대상 경로. 없으면 확대 불가. */ + target: () => { projectId: string; routeId: number } | null; + /** 보유 샘플의 최대 반폭(m). */ + sampledHalfWidth: () => number; + /** 재생성으로 받은 새 상세를 페이지 상태에 반영하고 다시 그린다. */ + applyDetail: (detail: SectionDetailResponse) => void; +} + +/** + * 요청 반폭까지 지반 샘플을 넓히는 함수를 만든다 — 보유 샘플로 충분하면 즉시 true + * (재계산 없음). 모자라면 그 폭으로 B05부터 재생성(영구 저장)하고 상세를 다시 받는다. + * 느려도 허용(2026-08-06 사용자 확정). 전역 [전체 측점 반영]과 카드 줌 버튼의 반폭 + * 확대가 같은 경로를 쓴다(2026-08-23 사용자 지시). + */ +export function createSampleWidener(deps: SampleWidenerDeps): (widthM: number) => Promise { + let running = false; + return async (widthM) => { + const target = deps.target(); + if (!target) return false; + if (widthM <= deps.sampledHalfWidth() + 1e-6) return true; + if (running) return false; // 재생성 중 중복 요청은 무시(버튼 연타). + running = true; + showLoadingOverlay(); + try { + await regenerateSections(target.projectId, target.routeId, widthM); + // 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다. + const fresh = await fetchSectionDetail(target.projectId, target.routeId); + // 공유 캐시도 새 상세로 바꿔 B05가 옛 값을 못 보게 한다. + replaceSectionDetail(target.projectId, target.routeId, fresh); + deps.applyDetail(fresh); + showToast(L("B06_Profile_Regenerate_Success"), "success"); + return true; + } catch (error) { + const detail = error instanceof Error ? ` ${error.message}` : ""; + showToast(`${L("B06_Profile_Regenerate_Failed")}${detail}`, "error"); + return false; + } finally { + running = false; + hideLoadingOverlay(); + } + }; +} diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 17f8828e..cc33ee2e 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -25,6 +25,11 @@ export interface StationControlDeps { detail: () => SectionDetailResponse | null; crossHalfWidth: () => number | undefined; sampledHalfWidth: () => number; + /** + * 요청 반폭까지 지반 샘플을 넓힌다(백엔드 재생성). 이미 충분하면 즉시 true, + * 재생성에 실패하면 false. 페이지가 구현한다(2026-08-23 사용자 지시). + */ + ensureSampledWidth: (widthM: number) => Promise; } /** 반폭·기슭막이·유입 구조물 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */ @@ -76,9 +81,11 @@ export function createStationControls(deps: StationControlDeps): StationControls } } - /** 개별 반폭 하한 2m·상한은 보유 샘플 폭 — 표시용이라 샘플 밖은 의미가 없다. */ - const clampStationWidth = (value: number): number => - Math.min(Math.max(value, 2), Math.max(deps.sampledHalfWidth(), 2)); + /** + * 개별 반폭 하한 2m. **상한은 두지 않는다**(2026-08-23 개편) — 계산 반폭(20m)을 + * 넘는 값은 `deps.ensureSampledWidth`가 백엔드 재생성으로 샘플을 넓힌 뒤 적용된다. + */ + const clampStationWidth = (value: number): number => Math.max(Math.round(value), 2); const stationWidthControl: StationWidthControl = { widthFor: (section) => { @@ -87,28 +94,22 @@ export function createStationControls(deps: StationControlDeps): StationControls const stored = section.design?.display_half_width_m; return typeof stored === "number" && stored > 0 ? stored : undefined; }, - adjust: (chainageM, deltaM) => { - const key = widthKey(chainageM); - const section = deps - .detail() - ?.cross_sections.find((entry) => Math.abs(entry.chainage_m - chainageM) < 0.01); - const stored = section?.design?.display_half_width_m; - const current = - stationWidths.get(key) ?? - (typeof stored === "number" && stored > 0 ? stored : undefined) ?? - deps.crossHalfWidth() ?? - deps.sampledHalfWidth(); - stationWidths.set(key, clampStationWidth(Math.round(current + deltaM))); - persistStationWidths(); - deps.refreshCard(chainageM); - }, - reset: (chainageM) => { - // 초기화 = 전역 반폭 복귀. 저장값(design)도 무시해야 하므로 세션에 전역값을 명시한다. - const globalWidth = deps.crossHalfWidth(); - if (globalWidth === undefined) stationWidths.delete(widthKey(chainageM)); - else stationWidths.set(widthKey(chainageM), clampStationWidth(globalWidth)); - persistStationWidths(); - deps.refreshCard(chainageM); + request: (chainageM, widthM) => { + const width = clampStationWidth(widthM); + const apply = (): void => { + stationWidths.set(widthKey(chainageM), width); + persistStationWidths(); + deps.refreshCard(chainageM); + }; + // 보유 샘플 안이면 표시만 바꾸면 된다 — 캐시(계산 20m)를 그대로 쓴다. + if (width <= deps.sampledHalfWidth() + 1e-6) { + apply(); + return; + } + // 샘플 밖 = 지반이 없는 폭. 백엔드에 그 폭으로 재계산을 맡기고, 끝나면 적용한다. + void deps.ensureSampledWidth(width).then((ready) => { + if (ready) apply(); + }); }, }; diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 036aaad8..0a3d4b5c 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -554,11 +554,3 @@ border-radius: var(--radius-inputs); padding: 1px 4px; } - -/* 개별 표시 반폭 ◀/▶/↺ — 암 경계 그룹 우측, 행 끝에 우측 맞춤(2026-08-06 사용자 지시). */ -.b06-cross-card__widthctl { - display: flex; - flex: 0 0 auto; - gap: 2px; - margin-left: auto; -}