diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 755d162fc..1015dbb3f 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -36,7 +36,12 @@ import type { CulvertDesignTrim } from "./B06_Section_UI_Cross_Culvert"; import { appendBoxOverlay, computeBoxLayout } from "./B06_Section_UI_Cross_Box"; import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; import { createBodyWiring } from "./B06_Section_UI_Cross_View_Bodies"; -import { appendCardFrame, applyStructureAreas } from "./B06_Section_UI_Cross_View_Frame"; +import { + appendCardFrame, + appendGroundProfile, + applyStructureAreas, + drawnContentBounds, +} from "./B06_Section_UI_Cross_View_Frame"; import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; import { computeCardCulvert } from "./B06_Section_UI_Cross_Culvert_Wire"; @@ -48,16 +53,14 @@ import type { RevetOffsetControl, StructureSpanControl, } from "./B06_Section_UI_Cross_Culvert_Wire"; -import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel"; -import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Dock"; -import { culvertCardState, structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure"; +import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure"; +import { buildCardPanels, emptyCardCulvertState } from "./B06_Section_UI_Cross_View_Panels"; import { attachZoomPan, buildZoomControls, cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom"; import type { CrossWidthActions } from "./B06_Section_UI_Cross_View_Zoom"; import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes"; -import type { RevetHighlightSetter, RevetKey, RevetPick } from "./B06_Section_UI_Cross_Culvert"; +import type { RevetHighlightSetter, RevetPick } from "./B06_Section_UI_Cross_Culvert"; import { REVET_FORM_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const"; -import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import { CROSS_PAD, CROSS_WIDTH, @@ -186,20 +189,8 @@ export function createCrossSectionCard( let highlightRevet: RevetPick | null = revetOffset?.highlightFor(section) ?? activeRevet; let setRevetActive: RevetHighlightSetter = () => undefined; let showRevetControl: (visible: boolean) => void = () => undefined; - let culvertInletIsBasin = false; // 유입이 집수정인가 — 조정창 표시·제목 분기 - // 이 카드의 기준벽이 **독립 기슭막이**인가(관 없는 숨김 세트). 조정창 걸음이 - // 배관(1m)과 다르다(0.1m — 2026-08-29 사용자). - let culvertHiddenPipe = false; - /** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */ - let culvertInletOptions = { revetAllowed: true, basinLUAllowed: true }; - /** 추가 기슭막이 상태(2026-08-22) — 끝 성토부 5m 이상(canAdd)·세워진 개수. */ - let culvertExtraState = { canAdd: false, count: 0 }; - /** 집수정 계류측 다단 상태(2026-08-22) — 조정창의 집수정 단 수 행이 쓴다. */ - let culvertBasinExtraState = { canAdd: false, count: 0 }; - /** 마지막 계산의 벽 제원(높이·재질) — 배관 벽은 순수 높이(2026-08-23 사용자). */ - let culvertWallSpecs = new Map(); - /** 마지막 계산의 실제 적용 d(상하) — d 미지정(자동) 벽의 ▲▼ 시작값. */ - let culvertAppliedD = new Map(); + /** 조정창이 읽는 마지막 계산 결과(벽 제원 · 단 수 등) — `_Cross_View_Panels`. */ + const culvertState = emptyCardCulvertState(); /** * 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를 * 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도 @@ -367,25 +358,11 @@ export function createCrossSectionCard( // 상태의 가운데 버튼 팬으로 끌어다 본다(2026-08-23 사용자 지시). 축 눈금은 팬을 따라 // 다시 그려지므로(_UI_Cross_Axes) 값이 어긋나지 않는다. const drawSamples = section.samples; - const segments: string[] = []; - let current: string[] = []; - for (const sample of drawSamples) { - if (!validElevation(sample)) { - if (current.length > 1) segments.push(current.join(" ")); - current = []; - continue; - } - const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration; - current.push(`${x(sample.offset_m ?? 0)},${y(elevated)}`); - } - if (current.length > 1) segments.push(current.join(" ")); - segments.forEach((points) => - plotLayer.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })), - ); + const toDisplayY = (elevation: number): number => + y(elevationMid + (elevation - elevationMid) * exaggeration); + appendGroundProfile(plotLayer, drawSamples, x, toDisplayY); if (section.design) { - const toDisplayY = (elevation: number): number => - y(elevationMid + (elevation - elevationMid) * exaggeration); // 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다. // 선택 여부와 무관하게 항상 깐다 — 평소에는 투명이고, 선택이 바뀔 때 카드를 새로 만들지 // 않고 클래스만 켜면 되므로 줌·팬이 살아남는다. @@ -460,11 +437,11 @@ export function createCrossSectionCard( // 눌러도 안 움직이는데 숫자만 커지는 것 방지. revetOffset?.syncApplied(section.chainage_m, "own", ownLayout.appliedAdjust); // 재질은 구조물 옵션(형태)이 정본이라 조정창에서 숨긴다 — 자리만 채운다. - culvertWallSpecs.set("own", { + culvertState.wallSpecs.set("own", { height: ownLayout.heightM, form: section.revetment?.form ?? REVET_FORM_DEFAULT, }); - culvertAppliedD.set("own", ownLayout.appliedAdjust.d ?? 0); + culvertState.appliedD.set("own", ownLayout.appliedAdjust.d ?? 0); const baseSetActive = setRevetActive; setRevetActive = (key) => { baseSetActive(key); @@ -512,16 +489,9 @@ export function createCrossSectionCard( ); } // 배수관 세트 — 조정창이 쓸 카드 상태를 받아 둔다(2026-08-19) · 링크 카드도 채운다(2026-08-24). - culvertInletIsBasin = !!culvertLayout?.basin; - culvertHiddenPipe = culvertLayout?.culvert.hidden_pipe === true; - if (culvertLayout) { - const state = culvertCardState(culvertLayout); - culvertInletOptions = state.inletOptions; - culvertExtraState = state.extraState; - culvertBasinExtraState = state.basinExtraState; - culvertWallSpecs = state.wallSpecs; - culvertAppliedD = state.appliedD; - } + culvertState.inletIsBasin = !!culvertLayout?.basin; + culvertState.hiddenPipe = culvertLayout?.culvert.hidden_pipe === true; + if (culvertLayout) Object.assign(culvertState, culvertCardState(culvertLayout)); if (boxLayout) { bodies.attachBox( appendBoxOverlay(plotLayer, boxLayout, x, toDisplayY, box ? bodies.toggleBox : undefined), @@ -561,7 +531,7 @@ export function createCrossSectionCard( valid, centerX: x(0), designElevation: hasDesign ? (designElevation as number) : null, - toY: (elevation) => y(elevationMid + (elevation - elevationMid) * exaggeration), + toY: toDisplayY, widthPx, heightPx, }); @@ -570,19 +540,7 @@ export function createCrossSectionCard( chartWrap.className = "b06-cross-card__chart-wrap"; chartWrap.style.setProperty("--b06-plot-left", `${CROSS_PAD.left}px`); // 팬 한계로 쓸 **그려진 범위** — 캐시 전 구간의 좌우 끝과 표고 상·하단. - const drawnOffsets = drawSamples.map((sample) => sample.offset_m ?? 0); - const drawnDisplayY = drawSamples - .filter(validElevation) - .map((sample) => y(elevationMid + (sample.elevation_m - elevationMid) * exaggeration)); - const contentBounds = - drawnOffsets.length && drawnDisplayY.length - ? { - x: x(Math.max(...drawnOffsets)), - width: x(Math.min(...drawnOffsets)) - x(Math.max(...drawnOffsets)), - y: Math.min(...drawnDisplayY), - height: Math.max(...drawnDisplayY) - Math.min(...drawnDisplayY), - } - : undefined; + const contentBounds = drawnContentBounds(drawSamples, x, toDisplayY); const zoomPan = attachZoomPan( svg, plotLayer, @@ -600,79 +558,24 @@ export function createCrossSectionCard( // 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다. const readout = buildAreaReadout(section.design, toggleArea); setChipActive = readout.setActive; - // 구조물 조정은 도면 안 오버레이 창(2026-08-21). 화면 좌(◀) = offset 증가 — - // 벽 기준 이동량(outward 부호)으로 환산해 넘긴다. - // 유입측 벽 — 기준벽과 유입측 다단(bextra). 빼먹으면 그 벽만 좌우가 뒤집힌다. - const inletSideRole = (role: RevetKey): boolean => - role === "inlet" || role.startsWith("bextra"); - const outwardOf = (role: RevetKey): number => - // 독립 기슭막이는 배관 유입/유출과 무관 — 정본 설치 측(좌 = +offset)으로 부호를 잡는다. - role === "own" - ? section.revetment?.side === "우" - ? -1 - : 1 - : ((section.uphill_side ?? "left") === "left") === inletSideRole(role) - ? 1 - : -1; - const heightOfWall = (key: RevetKey): number => culvertWallSpecs.get(key)?.height ?? 0; - const formOfWall = (key: RevetKey): string => - culvertWallSpecs.get(key)?.form ?? REVET_FORM_DEFAULT; - // 연동 중인 링크 카드의 조작은 **소유 측점**에 실린다(2026-08-24) — 풀면 자기 값. - const adjustTarget = (): CrossSection => - isLinkedCulvert && culvertLink && (revetLink?.linkedFor(section) ?? true) - ? culvertLink.source - : section; - // 연동을 푼 측점에서 **아직 손대지 않은** 벽은 소유 측점 값을 보여 준다 — 창이 - // 0(자동)을 보여 주면 첫 ◀ 한 번에 벽이 원위치로 튄다(2026-08-30). - const adjustOf = (key: RevetKey): WallAdjust => { - const stored = revetOffset?.storedAdjustFor(adjustTarget(), key); - if (stored) return stored; - const source = isLinkedCulvert && culvertLink ? culvertLink.source : section; - return revetOffset?.adjustFor(source, key) ?? { x: 0, d: null, h: null, m: null }; - }; - const panelContext = { + // 배수관 · 흙막이 조정창 두 벌(카드 안 · 좌측 사본) — `_Cross_View_Panels`(137-1). + const panel = buildCardPanels({ section, - scrollKey: section.station_id, - adjustChainage: () => adjustTarget().chainage_m, - isLinked: isLinkedCulvert, + culvertLink, + isLinkedCulvert, revetOffset, inletStructure, extraWalls, structureSpan, revetLink, - adjustOf, - outwardOf, - heightOfWall, - drawnWallKeys: () => [...culvertWallSpecs.keys()], - formOfWall, - appliedD: () => culvertAppliedD, - inletIsBasin: () => culvertInletIsBasin, - hiddenPipe: () => culvertHiddenPipe, - bodyGuard: () => !!(section.culvert ?? culvertLink?.source.culvert)?.body, - fordGuard: () => (section.culvert ?? culvertLink?.source.culvert)?.body === "ford", - inletOptions: () => culvertInletOptions, - extraState: () => culvertExtraState, - basinExtraState: () => culvertBasinExtraState, + state: culvertState, activeRevet: () => activeRevet, - setActiveRevet: (key: RevetPick | null) => { + setActiveRevet: (key) => { activeRevet = key; }, toggleRevet, - }; - const panel = buildStructurePanel(structurePanelDeps(panelContext)); - // 좌측 [횡단 조정] 사본 — 같은 deps라 어느 쪽 조작이든 카드 재렌더에서 두 창이 - // 함께 새 값으로 그려진다(2026-08-29 일원화). 스크롤 기억 키만 분리한다. - const dockPanel = buildStructurePanel( - structurePanelDeps({ ...panelContext, scrollKey: `dock:${section.station_id}` }), - { dock: true }, - ); - showRevetControl = (visible) => { - const key = visible ? activeRevet : null; - panel.show(key); - dockPanel.show(key); - if (key) adoptAdjustPanel(dockPanel.root); - else releaseAdjustPanel(dockPanel.root); - }; + }); + showRevetControl = (visible) => panel.show(visible ? activeRevet : null); showRevetControl(activeRevet !== null); // 세월교 조정창 — 배수관 창과 조작 축이 달라 따로 만든다(2026-08-25 사용자). // 우측 상단 줌 버튼이 원배율에서 표시 반폭까지 다룬다(하단 ◀/▶/↺ 폐지, 2026-08-23). diff --git a/B06_Section/B06_Section_UI_Cross_View_Frame.ts b/B06_Section/B06_Section_UI_Cross_View_Frame.ts index 66ef2bf9c..ca30247af 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Frame.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Frame.ts @@ -6,7 +6,7 @@ import type { CrossSection } from "./B06_Section_Api_Fetch"; import { computeStructureAreas, type DesignTrim } from "@util/common_util_cross_structure_areas"; -import { CROSS_PAD, L, svgElement, svgText } from "./B06_Section_UI_Section_Common"; +import { CROSS_PAD, L, svgElement, svgText, validElevation } from "./B06_Section_UI_Section_Common"; type Sample = CrossSection["samples"][number]; @@ -124,3 +124,43 @@ export function applyStructureAreas( design.cut_soil_area_m2 = Number(areas.cutSoilAreaM2.toFixed(4)); } } + +/** 원지반선 — 끊긴 샘플(무효 표고)마다 폴리선을 나눠 그린다. `toY` = 표고 → 화면 y(배율 반영). */ +export function appendGroundProfile( + plotLayer: SVGElement, + samples: Sample[], + x: (offset: number) => number, + toY: (elevation: number) => number, +): void { + const segments: string[] = []; + let current: string[] = []; + for (const sample of samples) { + if (!validElevation(sample)) { + if (current.length > 1) segments.push(current.join(" ")); + current = []; + continue; + } + current.push(`${x(sample.offset_m ?? 0)},${toY(sample.elevation_m)}`); + } + if (current.length > 1) segments.push(current.join(" ")); + segments.forEach((points) => + plotLayer.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })), + ); +} + +/** 팬 한계로 쓸 **그려진 범위** — 캐시 전 구간의 좌우 끝과 표고 상 · 하단(없으면 undefined). */ +export function drawnContentBounds( + samples: Sample[], + x: (offset: number) => number, + toY: (elevation: number) => number, +): { x: number; width: number; y: number; height: number } | undefined { + const offsets = samples.map((sample) => sample.offset_m ?? 0); + const displayY = samples.filter(validElevation).map((sample) => toY(sample.elevation_m)); + if (!offsets.length || !displayY.length) return undefined; + return { + x: x(Math.max(...offsets)), + width: x(Math.min(...offsets)) - x(Math.max(...offsets)), + y: Math.min(...displayY), + height: Math.max(...displayY) - Math.min(...displayY), + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_View_Panels.ts b/B06_Section/B06_Section_UI_Cross_View_Panels.ts new file mode 100644 index 000000000..7face8a37 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_View_Panels.ts @@ -0,0 +1,144 @@ +/* ============================================================================= + * B06_Section_UI_Cross_View_Panels.ts + * 횡단 카드 ↔ **배수관 · 흙막이 조정창 두 벌**(카드 안 · 좌측 [횡단 조정] 사본) 배선 — + * 카드 렌더러(`_UI_Cross_View.ts`)가 700줄을 넘어 떼었다(137-1). 그리기 · 기하는 하지 않는다. + * ========================================================================== */ + +import type { CrossSection } from "./B06_Section_Api_Fetch"; +import type { RevetKey, RevetPick } from "./B06_Section_UI_Cross_Culvert"; +import { REVET_FORM_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const"; +import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; +import type { + CulvertLink, + ExtraWallControl, + InletStructureControl, + RevetLinkControl, + RevetOffsetControl, + StructureSpanControl, +} from "./B06_Section_UI_Cross_Culvert_Wire"; +import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel"; +import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Dock"; +import { structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure"; + +/** 조정창이 읽는 카드 상태 — 마지막 계산 결과(그릴 때마다 바뀜). */ +export interface CardCulvertState { + /** 유입이 집수정인가 — 조정창 표시 · 제목 분기. */ + inletIsBasin: boolean; + /** 기준벽이 **독립 기슭막이**인가(관 없는 숨김 세트) — 걸음 0.1 m(2026-08-29 사용자). */ + hiddenPipe: boolean; + /** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */ + inletOptions: { revetAllowed: boolean; basinLUAllowed: boolean }; + /** 추가 기슭막이 상태(2026-08-22) — 끝 성토부 5m 이상(canAdd) · 세워진 개수. */ + extraState: { canAdd: boolean; count: number }; + /** 집수정 계류측 다단 상태(2026-08-22) — 조정창의 집수정 단 수 행이 쓴다. */ + basinExtraState: { canAdd: boolean; count: number }; + /** 마지막 계산의 벽 제원(높이 · 재질) — 배관 벽은 순수 높이(2026-08-23 사용자). */ + wallSpecs: Map; + /** 마지막 계산의 실제 적용 d(상하) — d 미지정(자동) 벽의 ▲▼ 시작값. */ + appliedD: Map; +} + +export const emptyCardCulvertState = (): CardCulvertState => ({ + inletIsBasin: false, + hiddenPipe: false, + inletOptions: { revetAllowed: true, basinLUAllowed: true }, + extraState: { canAdd: false, count: 0 }, + basinExtraState: { canAdd: false, count: 0 }, + wallSpecs: new Map(), + appliedD: new Map(), +}); + +export interface CardPanelsInput { + section: CrossSection; + culvertLink?: CulvertLink; + isLinkedCulvert: boolean; + revetOffset?: RevetOffsetControl; + inletStructure?: InletStructureControl; + extraWalls?: ExtraWallControl; + structureSpan?: StructureSpanControl; + revetLink?: RevetLinkControl; + state: CardCulvertState; + activeRevet: () => RevetPick | null; + setActiveRevet: (key: RevetPick | null) => void; + toggleRevet: (key: RevetPick) => void; +} + +/** 카드 안 조정창 + 좌측 사본을 만든다. `show` = 고른 벽(없으면 null)으로 두 창을 맞춤. */ +export function buildCardPanels(input: CardPanelsInput): { + root: HTMLElement; + show: (key: RevetPick | null) => void; +} { + const { section, culvertLink, isLinkedCulvert, revetOffset, revetLink, state } = input; + // 구조물 조정은 도면 안 오버레이 창(2026-08-21). 화면 좌(◀) = offset 증가 — + // 벽 기준 이동량(outward 부호)으로 환산해 넘긴다. + // 유입측 벽 — 기준벽과 유입측 다단(bextra). 빼먹으면 그 벽만 좌우가 뒤집힌다. + const inletSideRole = (role: RevetKey): boolean => role === "inlet" || role.startsWith("bextra"); + const outwardOf = (role: RevetKey): number => + // 독립 기슭막이는 배관 유입/유출과 무관 — 정본 설치 측(좌 = +offset)으로 부호를 잡는다. + role === "own" + ? section.revetment?.side === "우" + ? -1 + : 1 + : ((section.uphill_side ?? "left") === "left") === inletSideRole(role) + ? 1 + : -1; + const heightOfWall = (key: RevetKey): number => state.wallSpecs.get(key)?.height ?? 0; + const formOfWall = (key: RevetKey): string => + state.wallSpecs.get(key)?.form ?? REVET_FORM_DEFAULT; + // 연동 중인 링크 카드의 조작은 **소유 측점**에 실린다(2026-08-24) — 풀면 자기 값. + const adjustTarget = (): CrossSection => + isLinkedCulvert && culvertLink && (revetLink?.linkedFor(section) ?? true) + ? culvertLink.source + : section; + // 연동을 푼 측점에서 **아직 손대지 않은** 벽은 소유 측점 값을 보여 준다 — 창이 + // 0(자동)을 보여 주면 첫 ◀ 한 번에 벽이 원위치로 튄다(2026-08-30). + const adjustOf = (key: RevetKey): WallAdjust => { + const stored = revetOffset?.storedAdjustFor(adjustTarget(), key); + if (stored) return stored; + const source = isLinkedCulvert && culvertLink ? culvertLink.source : section; + return revetOffset?.adjustFor(source, key) ?? { x: 0, d: null, h: null, m: null }; + }; + const panelContext = { + section, + scrollKey: section.station_id, + adjustChainage: () => adjustTarget().chainage_m, + isLinked: isLinkedCulvert, + revetOffset, + inletStructure: input.inletStructure, + extraWalls: input.extraWalls, + structureSpan: input.structureSpan, + revetLink, + adjustOf, + outwardOf, + heightOfWall, + drawnWallKeys: () => [...state.wallSpecs.keys()], + formOfWall, + appliedD: () => state.appliedD, + inletIsBasin: () => state.inletIsBasin, + hiddenPipe: () => state.hiddenPipe, + bodyGuard: () => !!(section.culvert ?? culvertLink?.source.culvert)?.body, + fordGuard: () => (section.culvert ?? culvertLink?.source.culvert)?.body === "ford", + inletOptions: () => state.inletOptions, + extraState: () => state.extraState, + basinExtraState: () => state.basinExtraState, + activeRevet: input.activeRevet, + setActiveRevet: input.setActiveRevet, + toggleRevet: input.toggleRevet, + }; + const panel = buildStructurePanel(structurePanelDeps(panelContext)); + // 좌측 [횡단 조정] 사본 — 같은 deps라 어느 쪽 조작이든 카드 재렌더에서 두 창이 + // 함께 새 값으로 그려진다(2026-08-29 일원화). 스크롤 기억 키만 분리한다. + const dockPanel = buildStructurePanel( + structurePanelDeps({ ...panelContext, scrollKey: `dock:${section.station_id}` }), + { dock: true }, + ); + return { + root: panel.root, + show: (key) => { + panel.show(key); + dockPanel.show(key); + if (key) adoptAdjustPanel(dockPanel.root); + else releaseAdjustPanel(dockPanel.root); + }, + }; +}