diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index 41569733..24e51616 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -97,6 +97,7 @@ function buildExtraWall( height: number, material: RevetMaterial, floatGapM: number, + groundAt: (offset: number) => number, ): WallLayout { const thickness = REVET_THICKNESS_M; const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height; @@ -107,8 +108,16 @@ function buildExtraWall( const topFront = topJoint + outward * thickness; const frontXAt = (elevation: number): number => topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation); - // 하단 = 기준선(anchor) 아래 근입 0.5m 고정 — 높이 기준 "근입 0.5 위~상단"과 일치. - const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M; + // 하단 = 기준선(anchor) 아래 근입 0.5m — 근입은 **경사선(전면) 측 깊이** 기준이라 + // 전면 발끝 지반이 낮으면 그 아래 0.5까지 더 내린다(2026-08-22 사용자 ②). + let bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M; + if (floatGapM <= 1e-6) { + for (let pass = 0; pass < 6; pass += 1) { + const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation); + if (toeGround - REVET_EMBED_DEPTH_M >= bottomElevation - 1e-6) break; + bottomElevation = toeGround - REVET_EMBED_DEPTH_M; + } + } const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation }; const bottomFront: OffsetPoint = { offset: frontXAt(bottomElevation), @@ -228,6 +237,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult height, material, Math.max(0, base - groundAt(anchorX)), + groundAt, ); walls.push(wall); // 성토선: src → (수평 선반 x>0이면 선반 끝) → 이음선 상단점. 사면길이 = 경사부. diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index f89ee77e..2155fde6 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -339,7 +339,16 @@ export function computeCulvertLayout( // 더 높으면 그만큼 더 묻힌다. 전면 경사선(1:0.3)은 하단까지 연장. const frontXAt = (elevation: number): number => topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation); - const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M; + let bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M; + if (floatGapM <= 1e-6) { + // 근입 0.5m는 **경사선(전면) 측 깊이** 기준(2026-08-22 사용자 ②) — 전면 발끝 + // 지반이 더 낮으면 그 아래 0.5까지 내린다. 내려가면 발끝이 더 나가므로 수렴 반복. + for (let pass = 0; pass < 6; pass += 1) { + const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation); + if (toeGround - REVET_EMBED_DEPTH_M >= bottomElevation - 1e-6) break; + bottomElevation = toeGround - REVET_EMBED_DEPTH_M; + } + } const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation }; const bottomFront: OffsetPoint = { offset: frontXAt(bottomElevation), @@ -450,11 +459,20 @@ export function computeCulvertLayout( // 맞춤이 벽을 옮긴 뒤에도 옛 축을 그대로 써서, 가파른 지반에서 관이 벽 바닥 // 아래로 삐져나갔다(13+15.7 사용자 지적). const pipeStart = basinPipeEnd ?? inlet; + /** 유출 관 하단 끝점 목표 = 벽 바닥 기준 0.5m 상단(기준선)과 **전면 경사선의 + * 교차점**(2026-08-22 사용자 ① — 성토부선 시작 규칙과 같은 자리). */ + const outletPipeEnd = (wall: WallLayout): OffsetPoint => ({ + offset: + wall.points[2].offset + + wall.outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - wall.base), + elevation: wall.base, + }); // 유출 벽이 사면 끝과 다른 자리면 관 끝도 그 자리 기준으로 맞춘다(올림 연장분은 - // 유출 쪽 — 2026-08-20 확정). 관 끝은 벽 하단선 중점을 지나 정수 길이까지 나간다. + // 유출 쪽 — 2026-08-20 확정). 관 하단선은 전면 기준선 교차점을 지나 정수 길이로. if (outletWall) { - const runW = outletWallAnchor.offset - pipeStart.offset; - const riseW = outletWallAnchor.elevation - pipeStart.elevation; + const face = outletPipeEnd(outletWall); + const runW = face.offset - pipeStart.offset; + const riseW = face.elevation - pipeStart.elevation; const lenW = Math.hypot(runW, riseW); if (lenW > 0.5) { const scaleW = Math.ceil(lenW - 1e-6) / lenW; @@ -537,10 +555,10 @@ export function computeCulvertLayout( }, outletWallSpec.material, ); - // 관 하단선은 옮겨진 벽의 **하단선 중점을 지나야** 한다 — 옛 축을 그대로 늘리면 - // 가파른 지반에서 관이 벽 바닥 아래로 삐져나간다(2026-08-22 13+15.7 사용자 지적). - const runW = outletWallAnchor.offset - pipeStart.offset; - const riseW = outletWallAnchor.elevation - pipeStart.elevation; + // 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①). + const face = outletWall ? outletPipeEnd(outletWall) : outletWallAnchor; + const runW = face.offset - pipeStart.offset; + const riseW = face.elevation - pipeStart.elevation; const lenW = Math.hypot(runW, riseW) || 1; outlet.offset = pipeStart.offset + (runW / lenW) * target; outlet.elevation = pipeStart.elevation + (riseW / lenW) * target; diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 410dfd28..ff59df22 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -126,7 +126,31 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan ), ); - // 높이·재질 행(2026-08-22 사용자) — 높이 ±0.1m, 재질이 한계를 정한다. + // 재질 행(2026-08-22 사용자 ④ — 높이 행 **위**, 폭 축소: 한계는 툴팁으로). + const materialRow = document.createElement("label"); + materialRow.className = "b06-structure-panel__struct"; + const materialLabelEl = document.createElement("span"); + materialLabelEl.textContent = L("B06_Cross_Mat_Label"); + const materialSelect = document.createElement("select"); + materialSelect.className = "b06-structure-panel__select"; + const MATERIAL_LABEL: Record = { + dry: L("B06_Cross_Mat_Dry"), + wet: L("B06_Cross_Mat_Wet"), + concrete: L("B06_Cross_Mat_Concrete"), + }; + for (const material of REVET_MATERIALS) { + const option = document.createElement("option"); + option.value = material; + option.textContent = MATERIAL_LABEL[material]; + option.title = `${L("B06_Cross_Height_Label")} ~${materialLimit(material).toFixed(1)}m`; + materialSelect.append(option); + } + materialSelect.addEventListener("change", () => { + if (current) deps.setMaterial(current, materialSelect.value as RevetMaterial); + }); + materialRow.append(materialLabelEl, materialSelect); + + // 높이 행 — ±0.1m, 한계는 재질이 정한다. const heightRow = document.createElement("div"); heightRow.className = "b06-structure-panel__struct"; const heightLabel = document.createElement("span"); @@ -143,23 +167,7 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan L("B06_Cross_Height_Plus"), act((key) => deps.nudgeHeight(key, HEIGHT_STEP_M)), ); - const materialSelect = document.createElement("select"); - materialSelect.className = "b06-structure-panel__select"; - const MATERIAL_LABEL: Record = { - dry: L("B06_Cross_Mat_Dry"), - wet: L("B06_Cross_Mat_Wet"), - concrete: L("B06_Cross_Mat_Concrete"), - }; - for (const material of REVET_MATERIALS) { - const option = document.createElement("option"); - option.value = material; - option.textContent = `${MATERIAL_LABEL[material]}(~${materialLimit(material).toFixed(1)}m)`; - materialSelect.append(option); - } - materialSelect.addEventListener("change", () => { - if (current) deps.setMaterial(current, materialSelect.value as RevetMaterial); - }); - heightRow.append(heightLabel, heightMinus, heightValue, heightPlus, materialSelect); + heightRow.append(heightLabel, heightMinus, heightValue, heightPlus); // 유입측 구조물 형식 드롭다운(2026-08-22 사용자) — 자동/기슭막이/집수정 I·ㄴ·ㄷ. const structureRow = document.createElement("label"); @@ -218,7 +226,7 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan const head = document.createElement("div"); head.className = "b06-structure-panel__head"; head.append(title, closeButton); - root.append(head, value, structureRow, heightRow, extraRow, buttons); + root.append(head, value, structureRow, materialRow, heightRow, extraRow, buttons); return { root, @@ -252,17 +260,16 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan select.value = structure; } buttons.classList.toggle("is-hidden", !movable); + materialRow.classList.toggle("is-hidden", !movable); heightRow.classList.toggle("is-hidden", !movable); if (movable) { heightValue.textContent = `${deps.heightFor(key).toFixed(1)}m`; materialSelect.value = deps.materialFor(key); } - // 단 수 행은 **유출 벽에서만** — 성토부 5m 이상(의무)이거나 이미 단이 있을 때. + // 단 수 행은 유출 벽에서 **항상** 보인다(2026-08-22 사용자 ⑤ — 상태 따라 + // 나타났다 사라지면 레이아웃이 널뛴다). const extra = deps.extraState(); - extraRow.classList.toggle( - "is-hidden", - !(key === "outlet" && (extra.canAdd || extra.count > 0)), - ); + extraRow.classList.toggle("is-hidden", key !== "outlet"); countInput.value = String(extra.count); const adjust = deps.adjustFor(key); // 조작 요약 — 0이면 "자동". 좌우는 안/바깥, 상하는 위/아래로 적는다. diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 80e9dfa4..7b22c4a6 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -32,6 +32,7 @@ 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 { 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"; @@ -166,14 +167,16 @@ export interface StationWidthControl { reset: (chainageM: number) => void; } -// 기슭막이·유입 구조물·다단 제어 인터페이스는 Wire로 옮겼다(700줄 제한) — -// 기존 import 경로 유지를 위해 재수출한다. +// 기슭막이·유입 구조물·다단 제어 인터페이스는 Wire에 있다(700줄 제한) — 재수출. export type { ExtraWallControl, InletStructureControl, RevetOffsetControl, } from "./B06_Section_UI_Cross_Culvert_Wire"; +/** 측점별 줌·팬 상태 — 카드 재생성에도 배율 유지(2026-08-22 사용자 ③). */ +const cardZoomStates = new Map(); + export function createCrossSectionCard( section: CrossSection, selected: boolean, @@ -219,15 +222,14 @@ export function createCrossSectionCard( let activeRevet: RevetKey | null = revetOffset?.selectedFor(section) ?? null; let setRevetActive: RevetHighlightSetter = () => undefined; let showRevetControl: (visible: boolean) => void = () => undefined; - /** 지금 그린 관 길이(m) — 조정창이 "관 길이 8m"으로 적는다. 배수관 측점이 아니면 null. */ + /** 지금 그린 관 길이(m) — 조정창 표기용. 배수관 측점이 아니면 null. */ let culvertPipeLengthM: number | null = null; - /** 유입측이 집수정인가 — 조정창이 ◀/▶ 표시 여부·제목을 가른다. */ - let culvertInletIsBasin = false; + let culvertInletIsBasin = false; // 유입이 집수정인가 — 조정창 표시·제목 분기 /** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */ let culvertInletOptions = { revetAllowed: true, basinLUAllowed: true }; /** 추가 기슭막이 상태(2026-08-22) — 끝 성토부 5m 이상(canAdd)·세워진 개수. */ let culvertExtraState = { canAdd: false, count: 0 }; - /** 마지막 계산의 벽 제원(높이·재질) — 조정창 높이 행 표시·높이 조작 기준값. */ + /** 마지막 계산의 벽 제원(높이·재질) — 조정창 표시·높이 조작 기준. */ let culvertWallSpecs = new Map(); /** * 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를 @@ -495,8 +497,7 @@ export function createCrossSectionCard( toDisplayY, ); } - // 배수관 측점 세트 — 기존 도형 위에 추가만 한다(2026-08-19). 조정창이 쓸 - // 카드 상태(관 길이·집수정 여부·선택지·추가 벽 상태)를 여기서 받아 둔다. + // 배수관 세트 — 조정창이 쓸 카드 상태를 여기서 받아 둔다(2026-08-19). culvertPipeLengthM = culvertLayout?.pipe.lengthM ?? null; culvertInletIsBasin = !!culvertLayout?.basin; if (culvertLayout) { @@ -588,13 +589,19 @@ export function createCrossSectionCard( // 확대·축소 버튼, 드래그 팬, 더블클릭 원복(E-5). 도형 레이어 transform + non-scaling-stroke. const chartWrap = document.createElement("div"); chartWrap.className = "b06-cross-card__chart-wrap"; - const zoomPan = attachZoomPan(svg, plotLayer, widthPx, heightPx); + const zoomPan = attachZoomPan( + svg, + plotLayer, + widthPx, + heightPx, + cardZoomStates.get(section.station_id), + (state) => cardZoomStates.set(section.station_id, state), + ); // 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다. const readout = buildAreaReadout(section.design, toggleArea); setChipActive = readout.setActive; - // 구조물 위치 조정은 **도면 안 오버레이 창**으로 한다(2026-08-21 사용자 확정). - // 벽이 서는 쪽(outward): 상단측(유입)이 좌측이면 유입 벽은 좌(+), 유출 벽은 우(−). - // 화면 좌(◀)로 민다 = offset이 커진다 — 벽 기준 이동량으로 환산해 넘긴다. + // 구조물 조정은 도면 안 오버레이 창(2026-08-21). 화면 좌(◀) = offset 증가 — + // 벽 기준 이동량(outward 부호)으로 환산해 넘긴다. const outwardOf = (role: RevetKey): number => ((section.uphill_side ?? "left") === "left") === (role === "inlet") ? 1 : -1; const heightOfWall = (key: RevetKey): number => culvertWallSpecs.get(key)?.height ?? 0; @@ -608,7 +615,6 @@ export function createCrossSectionCard( revetOffset?.update(section.chainage_m, key, { x: adjustOf(key).x + screenDeltaM * outwardOf(key), }), - // 상하(대각) — ▲(위) = 성토선을 타고 노견 쪽, ▼(아래) = 계류 쪽. 수평 성분 1m. nudgeSlope: (key, deltaM) => revetOffset?.update(section.chainage_m, key, { d: adjustOf(key).d + deltaM }), nudgeHeight: (key, deltaM) => { @@ -633,8 +639,7 @@ export function createCrossSectionCard( optionsFor: () => culvertInletOptions, extraState: () => culvertExtraState, setExtraCount: (count) => { - // 줄어들며 사라지는 벽이 선택 중이면 선택을 유출 벽으로 옮긴다 — 다시 - // 그려질 때 없는 키를 강조하려다 조정창이 빈 대상으로 남는 것을 막는다. + // 줄어들며 사라지는 벽이 선택 중이면 선택을 유출 벽으로 옮긴다(빈 대상 방지). if (activeRevet?.startsWith("extra") && Number(activeRevet.slice(5)) >= count) { activeRevet = "outlet"; revetOffset?.select(section.chainage_m, "outlet"); diff --git a/B06_Section/B06_Section_UI_Cross_View_Zoom.ts b/B06_Section/B06_Section_UI_Cross_View_Zoom.ts index 9bd1044a..fddbc8c0 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Zoom.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Zoom.ts @@ -18,11 +18,22 @@ export interface ZoomPanHandle { reset: () => void; } +/** 줌·팬 상태 — 카드가 다시 그려질 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */ +export interface ZoomPanState { + scale: number; + tx: number; + ty: number; +} + export function attachZoomPan( svg: SVGSVGElement, plotLayer: SVGGElement, widthPx: number, heightPx: number, + /** 이전 카드의 줌·팬 상태 — 조정창 조작으로 카드가 다시 그려져도 배율 유지. */ + initial?: ZoomPanState, + /** 상태가 바뀔 때마다 부른다 — 카드 밖(뷰)이 담아 뒀다가 다음 렌더에 되돌린다. */ + onChange?: (state: ZoomPanState) => void, ): ZoomPanHandle { // 플롯 영역(축 안쪽) — 확대·축소의 중심이자 이동 한계의 기준이다. const plot = { @@ -32,11 +43,13 @@ export function attachZoomPan( height: Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1), }; const maxScale = 8; - let scale = 1; - let tx = 0; - let ty = 0; - const applyTransform = (): void => + let scale = initial?.scale ?? 1; + let tx = initial?.tx ?? 0; + let ty = initial?.ty ?? 0; + const applyTransform = (): void => { plotLayer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`); + onChange?.({ scale, tx, ty }); + }; // 확대한 도형이 플롯 영역을 항상 덮게 이동량을 가둔다 — 원배율에서는 이동량이 0으로 묶인다. const clampPan = (): void => { tx = Math.min(Math.max(tx, (plot.x + plot.width) * (1 - scale)), plot.x * (1 - scale)); @@ -100,6 +113,10 @@ export function attachZoomPan( /* 이미 해제됨 */ } }; + if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) { + clampPan(); + applyTransform(); + } svg.addEventListener("pointerup", endPan); svg.addEventListener("pointercancel", endPan); // 드래그(팬)로 끝난 클릭은 카드 선택으로 전파하지 않는다. diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 798d83c4..f5d95d92 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -272,6 +272,7 @@ export const ui_locales_b2 = { "{mat} height limit {limit}m — change material to go higher", ], B06_Cross_Height_Floor: ["최소 높이라 더 낮출 수 없습니다", "Already at the minimum height"], + B06_Cross_Mat_Label: ["재질", "Material"], B06_Cross_Mat_Dry: ["메쌓기", "Dry masonry"], B06_Cross_Mat_Wet: ["찰쌓기", "Wet masonry"], B06_Cross_Mat_Concrete: ["콘크리트", "Concrete"],