From 6bbed32f35bf70fa269c63bb48e54a4eae9b8b8d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 25 Jul 2026 13:56:46 +0900 Subject: [PATCH] 260725_4 --- .../B06_wf3_ProfileCross_Engine_Design.py | 59 +++++++-- .../B06_wf3_ProfileCross_UI_Cross_Design.ts | 122 ++++++++++-------- .../B06_wf3_ProfileCross_UI_Page.ts | 21 ++- .../B06_wf3_ProfileCross_UI_Section_View.ts | 3 + .../B06_wf3_ProfileCross_UI_Standard_Panel.ts | 12 ++ .../B06_wf3_ProfileCross_UI_Style_Cross.css | 54 +++++++- ui_template/ui_template_locale.ts | 25 +++- 7 files changed, 223 insertions(+), 73 deletions(-) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py index c64bcf78..f171d488 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py @@ -195,6 +195,8 @@ 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] = {} + # 절토 사면·지반 최초 교차거리(측별 캐시) — 교차 후 절토 종료용(N-2-4). + self._cut_cross: dict[str, float | None] = {} self.ditch_type = ditch_type # 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약). slope = cross_slope_pct / 100.0 @@ -311,6 +313,41 @@ class _SectionGeometry: self._rock_knee[side] = result return result + def _cut_slope_z(self, side: str, dist: float) -> float: + """절토 사면선 표고(2단계 무릎 반영). 지반 교차 클램프는 하지 않는다.""" + start_dist, start_z = self._slope_start(side) + knee = self.knee(side) if self.two_stage else None + if knee is not None: + knee_dist, knee_z = knee + if dist <= knee_dist: # 암반 구간(경계 아래): 암 경사 + return start_z + (dist - start_dist) / self.cut_ratio + return knee_z + (dist - knee_dist) / self.soil_cut_ratio # 토사 구간: 완만 + return start_z + (dist - start_dist) / self.cut_ratio + + def cut_cross_dist(self, side: str) -> float | None: + """절토 사면이 지반선과 처음 만나는 거리(절대 오프셋). 이후는 절토 없음(N-2-4). + + 지면과 1회 교차하면 그다음 경사(2단계 전환 포함)는 의미가 없으므로 교차점에서 + 절토를 종료한다. 시작(노면 끝)부터 사면이 지반 위면 교차거리=시작(절토 없음), + 끝까지 못 만나면 None. + """ + if side in self._cut_cross: + return self._cut_cross[side] + result: float | None = None + if self._ground_at is not None: + start_dist, _start_z = self._slope_start(side) + step = 0.05 + dist = start_dist + max_dist = start_dist + 500.0 + while dist <= max_dist: + signed = dist if side == "left" else -dist + if self._cut_slope_z(side, dist) - self._ground_at(signed) >= 0: + result = dist + break + dist += step + self._cut_cross[side] = result + return result + def design_z(self, offset_m: float, ground_m: float) -> float: """offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종).""" side = "left" if offset_m >= 0 else "right" @@ -339,16 +376,11 @@ class _SectionGeometry: dist = abs(offset_m) run = dist - start_dist if role == "cut": - knee = self.knee(side) if self.two_stage else None - if knee is not None: - knee_dist, knee_z = knee - if dist <= knee_dist: # 암반 구간(경계 아래): 암 경사 - slope_line = start_z + (dist - start_dist) / self.cut_ratio - else: # 토사 구간(경계 위): 무릎에서 토사 경사로 완만 - slope_line = knee_z + (dist - knee_dist) / self.soil_cut_ratio - else: - slope_line = start_z + run / self.cut_ratio - return min(slope_line, ground_m) + # 지반과 1회 교차하면 그 이후 절토는 의미 없음 → 지반 추종(N-2-4). + cross = self.cut_cross_dist(side) + if cross is not None and dist >= cross: + return ground_m + return min(self._cut_slope_z(side, dist), ground_m) fill_line = start_z - run / self.fill_ratio return max(fill_line, ground_m) @@ -362,6 +394,13 @@ class _SectionGeometry: knee = self.knee(side) if role == "cut" else None if knee is not None: points.append(knee[0] if side == "left" else -knee[0]) + # 절토 사면·지반 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(N-2-4). + for side in ("left", "right"): + role = self.left_role if side == "left" else self.right_role + if role == "cut": + cross = self.cut_cross_dist(side) + if cross is not None: + points.append(cross if side == "left" else -cross) return points 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 83c31a6a..fa215f05 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts @@ -47,9 +47,11 @@ export function sectionModeLabel(mode: SectionMode | undefined): string { const key = MODE_OPTIONS.find(([value]) => value === mode)?.[1]; return key ? ui_locales[key][currentLanguageIndex] : "-"; } +// 좌/우 버튼 = 노면 횡단 경사 방향(물을 모으는 쪽 = 측구가 있다면 그 위치). 양성도 측구는 +// 없지만 "생략된 측구"를 가정한 경사 방향을 여기서 정한다(N-2-3). const DITCH_OPTIONS: Array<[DitchSide, keyof typeof ui_locales]> = [ - ["left", "B06_Design_Ditch_Left"], - ["right", "B06_Design_Ditch_Right"], + ["left", "B06_Design_SlopeDir_Left"], + ["right", "B06_Design_SlopeDir_Right"], ]; const DITCH_TYPE_OPTIONS: Array<[DitchType, keyof typeof ui_locales]> = [ ["standard", "B06_Design_DitchType_Standard"], @@ -236,8 +238,9 @@ export function buildDesignControls( // 컨트롤 상호작용이 카드 선택 클릭으로 전파되지 않게 한다. bar.addEventListener("click", (event) => event.stopPropagation()); - // 측구 방향 선택이 필요한 경우 = 양절(both_cut). 편절은 절토측 자동, 양성은 측구 없음. - const needsDitch = (): boolean => state.mode === "both_cut"; + // 경사 방향 선택이 필요한 경우 = 양절(both_cut)·양성(both_fill). 편절은 절토측 자동. + // 양성은 측구가 없어도 노면 기울기 방향(생략된 측구 방향)을 여기서 지정한다(N-2-3). + const needsDitch = (): boolean => state.mode === "both_cut" || state.mode === "both_fill"; const emit = (): void => { if (!state.ground || !state.mode) return; // L형 측구는 암 전용 — 토사로 되돌리면 일반측구로 강등해 서버 거부를 예방한다. @@ -254,15 +257,10 @@ export function buildDesignControls( }; // 지반유형: 제목행 배치용으로 분리 반환(D-6). 라벨 삭제(3번) — 버튼만. - const groundSegment = segment( - "", - GROUND_OPTIONS, - state.ground, - (value) => { - state.ground = value; - emit(); - }, - ); + const groundSegment = segment("", GROUND_OPTIONS, state.ground, (value) => { + state.ground = value; + emit(); + }); groundSegment.classList.add("b06-design__seg--header"); // 단면 유형은 지형에서 자동 판정되며(D-2), 제목행 pill로 표시한다(E-3, Cross_View에서 생성). // 아래 옵션은 상시 노출하되 선행 조건 미충족 시 비활성 처리한다(E-6). @@ -271,19 +269,17 @@ export function buildDesignControls( // 측구 생성 여부(자동 판정 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(); - }, - state.mode !== "both_cut", - L("B06_Design_Disabled_BothCutOnly"), - ), + // 경사 방향(좌/우): 양절·양성에서 활성. 항상 인라인 노출(오버플로 대상 아님). 라벨 삭제(E-7). + const slopeDirSeg = segment( + "", + DITCH_OPTIONS, + state.ditch, + (value) => { + state.ditch = value; + emit(); + }, + !needsDitch(), + L("B06_Design_Disabled_BothCutOnly"), ); // 측구 생성 토글(D-1): 측구 있는 단면에서만 활성. 버튼명 "측구" 고정, 컬러로 상태 표시(E-7). const ditchToggle = toggle( @@ -298,20 +294,17 @@ export function buildDesignControls( !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(); - }, - !rockCut || !ditchOn, - !ditchOn ? L("B06_Design_Disabled_NoDitchType") : L("B06_Design_Disabled_RockCut"), - ), + const ditchTypeSeg = segment( + L("B06_Design_DitchType_Legend"), + DITCH_TYPE_OPTIONS, + state.ditchType, + (value) => { + state.ditchType = value; + emit(); + }, + !rockCut || !ditchOn, + !ditchOn ? L("B06_Design_Disabled_NoDitchType") : L("B06_Design_Disabled_RockCut"), ); // 2단계 경사 토글: 암 지반 + 절토 단면에서만 활성. 활성="복합경사"/비활성="단경사"(E-7). const twoStage = toggle( @@ -326,18 +319,11 @@ export function buildDesignControls( !rockCut, L("B06_Design_Disabled_RockCut"), ); - bar.append(twoStage.wrap); // 포장 토글: 라벨 삭제, 버튼명 활성="포장"/비활성="비포장"(컬러 유지) — E-7. - const paved = toggle( - "", - state.paved, - L("B06_Design_Paved_On"), - L("B06_Design_Paved_Off"), - () => { - state.paved = !state.paved; - emit(); - }, - ); + const paved = toggle("", state.paved, L("B06_Design_Paved_On"), L("B06_Design_Paved_Off"), () => { + state.paved = !state.paved; + emit(); + }); // B05 법정 경사 분석이 포장을 제안한 측점은 근거 문구를 배지·툴팁으로 표기한다. if (design?.pavement_suggested) { paved.button.title = L("B06_Design_Paved_Suggested"); @@ -347,10 +333,42 @@ export function buildDesignControls( badge.title = L("B06_Design_Paved_Suggested"); paved.wrap.append(badge); } - bar.append(paved.wrap); + + // 오버플로 드롭다운(N-2-2): 컨트롤이 카드 폭에서 한 행을 넘치면 뒤쪽부터 우측 "⋯" + // 드롭다운(세로 배치)으로 옮겨 다음 행으로 밀리는 것을 막는다. 경사 방향은 항상 인라인, + // 이동 우선순위(뒤에서부터): 포장 → 복합경사 → 측구형식 → 측구 토글. + const moveable = [ditchToggle.wrap, ditchTypeSeg, twoStage.wrap, paved.wrap]; + const more = document.createElement("details"); + more.className = "b06-design__more"; + const moreSummary = document.createElement("summary"); + moreSummary.className = "b06-design__more-summary"; + moreSummary.textContent = "⋯"; + moreSummary.title = L("B06_Design_More"); + const morePanel = document.createElement("div"); + morePanel.className = "b06-design__more-panel"; + more.append(moreSummary, morePanel); + bar.append(slopeDirSeg, ...moveable, more); + + const reflow = (): void => { + // 후보 전부 인라인 복귀 → more 숨김 → 넘치면 뒤에서부터 패널로 이동. + for (const element of moveable) bar.insertBefore(element, more); + morePanel.replaceChildren(); + more.hidden = true; + if (bar.clientWidth <= 0) return; + for ( + let index = moveable.length - 1; + index >= 0 && bar.scrollWidth > bar.clientWidth + 1; + index -= 1 + ) { + more.hidden = false; + morePanel.insertBefore(moveable[index], morePanel.firstChild); + } + }; + const overflowObserver = new ResizeObserver(() => reflow()); + overflowObserver.observe(bar); + requestAnimationFrame(reflow); // 암 경계선 제어는 그래프 X축 제목 행으로 이동(E-7, Cross_View에서 배치). - // 절·성토 면적 readout은 그래프 중상단 오버레이로 이동(E-4, Cross_View에서 배치). return { bar, groundSegment }; } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index 74fa64d7..8b89a9d1 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -190,6 +190,25 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } } + /** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다. + * handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만 + * 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */ + async function applyPanelToAll(): Promise { + if (!sectionDetail) return; + const targets = sectionDetail.cross_sections.filter((section) => section.design); + if (!targets.length) return; + showLoadingOverlay(); + try { + for (const section of targets) { + const change = changeFromDesign(section.chainage_m); + if (change) await handleDesignChange(section.chainage_m, change); + } + showToast(L("B06_Std_ApplyAll_Success"), "success"); + } finally { + hideLoadingOverlay(); + } + } + /* ── 암 경계선 오프셋(측점별) 세션 저장소 ───────────────────────────── * 서버 재계산 없이 프론트 세션(sessionStorage)에 보관하고, 종횡단 확정 시 * cross_patches로 DB(data.design.rock_boundary_offset_m)에 병합한다. @@ -396,7 +415,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { rockBoundaryStep = context.rock_boundary_step_m; // 표준 횡단면 설정 패널 장착(세션값 우선, 없으면 config 기본값). - standardPanel = createStandardPanel(projectId, context.standard_cross_section); + standardPanel = createStandardPanel(projectId, context.standard_cross_section, applyPanelToAll); standardPanelSlot.append(standardPanel.root); if (context.route_id === null) { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index 54cea947..d78c761e 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -149,6 +149,9 @@ export function createSectionView( 1, Math.floor((renderWidth + CROSS_GRID_GAP) / (CROSS_GRID_MIN_WIDTH + CROSS_GRID_GAP)), ); + // CSS auto-fill의 자체 열 수와 JS columnCount가 어긋나면 같은 행 높이 그룹핑이 실제 + // 렌더 행과 달라져 카드 내부가 뒤죽박죽된다(N-2-6). 열 수를 JS가 명시해 일치시킨다. + grid.style.gridTemplateColumns = `repeat(${columnCount}, minmax(0, 1fr))`; cachedCardWidth = (renderWidth - (columnCount - 1) * CROSS_GRID_GAP) / columnCount; cachedRowHeight.clear(); if (detail.cross_sections.length) { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts index 6b01a57a..df4a19f9 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts @@ -158,6 +158,7 @@ const FIELD_SPECS: NumberFieldSpec[] = [ export function createStandardPanel( projectId: string, defaults: StandardCrossSection, + onApplyAll?: () => void | Promise, ): StandardPanelController { // 세션값 우선, 없으면 config 기본값. defaults는 복원 기준으로 보존한다. const sessionValue = readSession(projectId); @@ -247,6 +248,17 @@ export function createStandardPanel( }); const actions = document.createElement("div"); actions.className = "b06-std__actions"; + // [전체 반영](N-2-1): 패널 수치를 design 보유 전 측점에 서버 재계산으로 일괄 반영한다. + // 측점별 버튼 선택값(지반/단면 등)은 보존하고 표준단면 수치만 갱신한다. + if (onApplyAll) { + actions.append( + createButton({ + label: L("B06_Std_ApplyAll"), + variant: "filled", + onClick: () => void onApplyAll(), + }), + ); + } actions.append(resetButton); root.append(body, loader, actions); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css index fcc1c4eb..7468c881 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css @@ -320,10 +320,11 @@ font-weight: var(--font-weight-bold); } -/* 측점 표준횡단 설계 지정 컨트롤 (카드 헤더 아래) — 좌측 여유 추가(2번). */ +/* 측점 표준횡단 설계 지정 컨트롤 (카드 헤더 아래) — 좌측 여유 추가(2번). + 한 행 고정(nowrap): 넘치는 컨트롤은 다음 행으로 밀지 않고 "⋯" 드롭다운으로 이동(N-2-2). */ .b06-design { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; gap: var(--spacing-8) var(--spacing-16); padding: var(--spacing-8) 0 var(--spacing-8) var(--spacing-16); @@ -331,13 +332,60 @@ margin-bottom: var(--spacing-8); } +/* 오버플로 드롭다운(N-2-2): 넘친 컨트롤을 담는 우측 맞춤 "⋯" 메뉴. 세로 배치. */ +.b06-design__more { + position: relative; + flex: 0 0 auto; + margin-left: auto; +} + +.b06-design__more-summary { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 22px; + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: var(--color-text-secondary); + font-size: 0.9rem; + line-height: 1; + cursor: pointer; + list-style: none; +} + +.b06-design__more-summary::-webkit-details-marker { + display: none; +} + +.b06-design__more[open] .b06-design__more-summary { + color: var(--color-surface); + background: var(--color-royal-amethyst); + border-color: var(--color-royal-amethyst); +} + +.b06-design__more-panel { + position: absolute; + right: 0; + top: calc(100% + 4px); + z-index: 3; + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface-raised); + box-shadow: var(--shadow-sm); +} + .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); diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 1e0032a5..cac6b76a 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -806,10 +806,10 @@ export const ui_locales = { B06_Design_Ground_Ripping: ["리핑암", "Ripping rock"], B06_Design_Ground_Blasting: ["발파암", "Blasting rock"], B06_Design_Mode_Legend: ["단면유형", "Section type"], - B06_Design_Mode_LeftCut: ["좌 절토", "Left cut"], - B06_Design_Mode_RightCut: ["우 절토", "Right cut"], - B06_Design_Mode_BothCut: ["양절", "Both cut"], - B06_Design_Mode_BothFill: ["양성", "Both fill"], + B06_Design_Mode_LeftCut: ["편측 성토", "One-side fill"], + B06_Design_Mode_RightCut: ["편측 성토", "One-side fill"], + B06_Design_Mode_BothCut: ["양측 절토", "Both cut"], + B06_Design_Mode_BothFill: ["양측 성토", "Both fill"], B06_Design_Ditch_Legend: ["측구위치", "Ditch side"], B06_Design_Ditch_Left: ["좌", "Left"], B06_Design_Ditch_Right: ["우", "Right"], @@ -829,9 +829,12 @@ export const ui_locales = { B06_Design_Ditch_On: ["측구", "Ditch"], B06_Design_Ditch_Off: ["측구", "Ditch"], B06_Design_Disabled_BothCutOnly: [ - "양절 단면에서만 선택할 수 있습니다.", - "Available only for both-cut sections.", + "양측 절토·양측 성토 단면에서만 경사 방향을 바꿀 수 있습니다.", + "Slope direction is adjustable only for both-cut and both-fill sections.", ], + B06_Design_More: ["더보기", "More"], + B06_Design_SlopeDir_Left: ["좌경사", "Slope left"], + B06_Design_SlopeDir_Right: ["우경사", "Slope right"], B06_Design_Disabled_RockCut: [ "암(리핑/발파) 지반의 절토 단면에서만 사용할 수 있습니다.", "Available only for rock ground with a cut section.", @@ -880,10 +883,18 @@ export const ui_locales = { "L-type ditch is chosen per cross-section drawing.", ], B06_Std_Reset: ["기본값 복원", "Restore defaults"], + B06_Std_ApplyAll: ["전체 측점 반영", "Apply to all stations"], + B06_Std_ApplyAll_Success: [ + "패널 설정을 전체 측점에 반영했습니다.", + "Applied panel settings to all stations.", + ], B06_Std_Load_Title: ["다른 프로젝트에서 불러오기", "Load from another project"], B06_Std_Load_Select: ["프로젝트 선택", "Select project"], B06_Std_Load_Placeholder: ["— 프로젝트 선택 —", "— Select a project —"], - B06_Std_Load_Empty: ["같은 회사에 불러올 설계값이 없습니다.", "No saved designs in your company."], + B06_Std_Load_Empty: [ + "같은 회사에 불러올 설계값이 없습니다.", + "No saved designs in your company.", + ], B06_Std_Load_Loading: ["불러오는 중…", "Loading…"], B06_Std_Load_Apply: ["현재 설정에 적용", "Apply to current settings"], B06_Std_Load_Applied: ["적용되었습니다.", "Applied."],