feat(B05,B06): 구조물 배치 컨테이너를 B06에 장착하고 [횡단 조정] 사본을 세운다

B05/B06 인터페이스 일원화 1차 (2026-08-29 사용자 확정 계획):
- A00_Common/b_structures_section.ts: B05 구조물 배치 폼·목록 공용 진입점
  (재수출 + 스타일 동봉)
- B06 좌측 패널에 [구조물 배치] 섹션 + 하단 dock [목록][구분선][저장버튼]
  (B05 동일 템플릿). 구조물 조작은 세션 pending으로 쌓여 기존
  flushPendingStructures([임시저장]·[확정])로 정본 반영
- 선택 연동: 목록·폼 → 최근접 카드 focusStation, 횡단도 벽·세월교·BOX →
  좌측 폼에 소유 측점 시설 로드(wireStructureSelection)
- [횡단 조정] 하위 컨테이너(Adjust_Dock): 오버레이 조정창을 같은 deps로
  사본 인스턴스 생성해 세움 — 로직 이동 0, 카드 재렌더로 자동 동기.
  9키·십자 행은 사본에서 숨김(사용자 지시 7), 방향키 이중 입력 방지
- A군 시설 추가·이동·삭제는 B06에서 안내 토스트(배수유역 체인은 B05 몫)

검증: 공용 브라우저 — 구간값 길이 사본 + 10.0→11.0m 양측 동기,
오버레이 - 원복, ✕ 닫기 시 사본 해제. B05 회귀 없음. typecheck 통과.
This commit is contained in:
2026-08-29 14:20:49 +09:00
parent 11abd303e7
commit 2e28af6e86
11 changed files with 427 additions and 66 deletions
+17
View File
@@ -0,0 +1,17 @@
/* =============================================================================
* b_structures_section.ts
* 「구조물 배치」 컨테이너 공용 진입점 — B05·B06 두 페이지가 같은 주소로 쓴다
* (2026-08-29 사용자: B05/B06 인터페이스 일원화 — 배치 폼·하단 목록 템플릿 동일).
*
* 구현은 B05 모듈(`B05_Profile_UI_Structures_Panel` 외)에 있다. 이 파일은 재수출과
* 스타일 동봉만 한다 — B06이 이 파일 하나만 import해도 B05와 같은 모양이 실린다.
* ========================================================================== */
import "../B05_Profile/B05_Profile_UI_Style.css";
import "../B05_Profile/B05_Profile_UI_Style_Structures.css";
export {
createStructuresSection,
GROUP_LABELS,
type PipeFacilityItem,
type StructuresSection,
} from "../B05_Profile/B05_Profile_UI_Structures_Panel";
+60
View File
@@ -0,0 +1,60 @@
/* =============================================================================
* B06_Section_UI_Adjust_Dock.ts
* 좌측 [구조물 배치] 섹션 안 **[횡단 조정] 하위 컨테이너**(2026-08-29 사용자:
* B05/B06 일원화 — 오버레이 조정창 항목을 좌측에도 보이되, 기존 폼과 구분되게
* 접이식 하위 컨테이너에 담는다. 9키·십자 위치제어는 오버레이 전용).
*
* 카드가 조정창을 만들 때 같은 deps로 **사본 창**을 하나 더 만들어 이 슬롯에
* 세운다(adopt). 값·동작이 같은 함수라 오버레이·좌측 어느 쪽을 만져도 카드
* 재렌더에서 두 창이 함께 새 값으로 그려진다 — 별도 동기화 코드가 없다.
* 카드가 여럿이어도 슬롯은 하나 — 마지막으로 고른 구조물의 사본만 선다.
* ========================================================================== */
let dockRoot: HTMLDetailsElement | null = null;
let slot: HTMLElement | null = null;
let placeholder: HTMLElement | null = null;
function ensureDock(): void {
if (dockRoot) return;
dockRoot = document.createElement("details");
dockRoot.className = "b06-adjust-dock";
dockRoot.open = true;
const summary = document.createElement("summary");
summary.textContent = "횡단 조정";
placeholder = document.createElement("p");
placeholder.className = "b06-adjust-dock__empty";
placeholder.textContent = "횡단도에서 구조물(벽·구체)을 고르면 조정 항목이 여기에 뜹니다.";
slot = document.createElement("div");
slot.className = "b06-adjust-dock__slot";
dockRoot.append(summary, placeholder, slot);
}
/** 하위 컨테이너 루트 — 페이지가 [구조물 배치] 섹션 본문에 붙인다. */
export function adjustDockRoot(): HTMLElement {
ensureDock();
return dockRoot!;
}
/** 페이지 진입 시 초기 상태(빈 슬롯 + 안내문)로 되돌린다. */
export function resetAdjustDock(): void {
ensureDock();
slot!.replaceChildren();
placeholder!.hidden = false;
}
/** 조정창 사본을 슬롯에 세운다 — 이미 다른 사본이 있으면 갈아 끼운다. */
export function adoptAdjustPanel(panelRoot: HTMLElement): void {
ensureDock();
panelRoot.classList.add("b06-structure-panel--dock");
if (slot!.firstChild !== panelRoot || slot!.childNodes.length !== 1) {
slot!.replaceChildren(panelRoot);
}
placeholder!.hidden = true;
}
/** 이 사본이 슬롯에 서 있으면 내린다 — 다른 사본이 이미 대신 서 있으면 손대지 않는다. */
export function releaseAdjustPanel(panelRoot: HTMLElement): void {
if (!slot || !slot.contains(panelRoot)) return;
slot.replaceChildren();
placeholder!.hidden = false;
}
@@ -42,10 +42,12 @@ export interface BoxPanelHandle {
/** 한 걸음(m) — 사용자 지정 0.1m. */
const STEP_M = 0.1;
/** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */
export function buildBoxPanel(deps: BoxPanelDeps): BoxPanelHandle {
/** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다.
* `opts.dock` — 좌측 [횡단 조정] 사본(2026-08-29): 흐름 배치 + 방향키 미장착. */
export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): BoxPanelHandle {
const shell = buildPanelShell(() => deps.close());
const { root, title } = shell;
if (opts?.dock) root.classList.add("b06-structure-panel--dock");
const value = document.createElement("div");
value.className = "b06-structure-panel__value";
@@ -93,7 +95,9 @@ export function buildBoxPanel(deps: BoxPanelDeps): BoxPanelHandle {
root.append(value, moveRow.row);
const arrows = bindArrowKeys(root, () => [moveRow.controls]);
const arrows = opts?.dock
? { attach: (): void => undefined, detach: (): void => undefined }
: bindArrowKeys(root, () => [moveRow.controls]);
function render(): void {
if (!current) return;
@@ -65,10 +65,12 @@ const ANGLE_STEP_DEG = 5;
/** 관경 선택지(mm) — B05 레지스트리 `ford_bridge.pipe_diameter_mm` choices와 같다. */
const DIAMETER_CHOICES_MM = [800, 1000, 1200, 1500];
/** 세월교 측벽 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */
export function buildFordPanel(deps: FordPanelDeps): FordPanelHandle {
/** 세월교 측벽 조정 오버레이 창을 만든다. 처음에는 숨어 있다.
* `opts.dock` — 좌측 [횡단 조정] 사본(2026-08-29): 흐름 배치 + 방향키 미장착. */
export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): FordPanelHandle {
const shell = buildPanelShell(() => deps.close());
const { root, title } = shell;
if (opts?.dock) root.classList.add("b06-structure-panel--dock");
const value = document.createElement("div");
value.className = "b06-structure-panel__value";
@@ -233,7 +235,9 @@ export function buildFordPanel(deps: FordPanelDeps): FordPanelHandle {
moveRow.row,
);
const arrows = bindArrowKeys(root, () => [moveRow.controls]);
const arrows = opts?.dock
? { attach: (): void => undefined, detach: (): void => undefined }
: bindArrowKeys(root, () => [moveRow.controls]);
function render(): void {
if (!current) return;
@@ -111,11 +111,17 @@ const LENGTH_STEP_M = SPAN_STEP_M * 2;
*/
const panelScrollTops = new Map<string, number>();
/** 구조물 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */
export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHandle {
/** 구조물 조정 오버레이 창을 만든다. 처음에는 숨어 있다.
* `opts.dock` — 좌측 [횡단 조정] 사본(2026-08-29 일원화): 흐름 배치 스타일을 입고,
* 방향키 문서 리스너를 달지 않는다(오버레이 창과 이중으로 눌리는 것 방지). */
export function buildStructurePanel(
deps: StructurePanelDeps,
opts?: { dock?: boolean },
): StructurePanelHandle {
const root = document.createElement("div");
root.className = "b06-structure-panel";
root.classList.add("is-hidden");
if (opts?.dock) root.classList.add("b06-structure-panel--dock");
root.addEventListener("scroll", () => {
if (!root.classList.contains("is-hidden")) panelScrollTops.set(deps.scrollKey, root.scrollTop);
});
@@ -482,7 +488,7 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
root.classList.toggle("is-hidden", key === null);
document.removeEventListener("keydown", onArrowKey);
if (!key) return;
document.addEventListener("keydown", onArrowKey);
if (!opts?.dock) document.addEventListener("keydown", onArrowKey);
// 다단 벽 — 유출측(extra0…)과 유입·집수정 계류측(bextra0…)이 같은 이름을 쓴다.
const isExtra = key.startsWith("extra") || key.startsWith("bextra");
const extraIndex = key.startsWith("bextra") ? Number(key.slice(6)) : Number(key.slice(5));
+41 -29
View File
@@ -42,6 +42,7 @@ import type {
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 { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
import type { CrossWidthActions, ZoomPanState } from "./B06_Section_UI_Cross_View_Zoom";
@@ -685,36 +686,47 @@ export function createCrossSectionCard(
const source = isLinkedCulvert && culvertLink ? culvertLink.source : section;
return revetOffset?.adjustFor(source, key) ?? { x: 0, d: null, h: null, m: null };
};
const panel = buildStructurePanel(
structurePanelDeps({
section,
scrollKey: section.station_id,
adjustChainage: () => adjustTarget().chainage_m,
isLinked: isLinkedCulvert,
revetOffset,
inletStructure,
extraWalls,
structureSpan,
revetLink,
adjustOf,
outwardOf,
heightOfWall,
formOfWall,
appliedD: () => culvertAppliedD,
pipeLengthM: () => culvertPipeLengthM,
inletIsBasin: () => culvertInletIsBasin,
hiddenPipe: () => culvertHiddenPipe,
inletOptions: () => culvertInletOptions,
extraState: () => culvertExtraState,
basinExtraState: () => culvertBasinExtraState,
activeRevet: () => activeRevet,
setActiveRevet: (key) => {
activeRevet = key;
},
toggleRevet,
}),
const panelContext = {
section,
scrollKey: section.station_id,
adjustChainage: () => adjustTarget().chainage_m,
isLinked: isLinkedCulvert,
revetOffset,
inletStructure,
extraWalls,
structureSpan,
revetLink,
adjustOf,
outwardOf,
heightOfWall,
formOfWall,
appliedD: () => culvertAppliedD,
pipeLengthM: () => culvertPipeLengthM,
inletIsBasin: () => culvertInletIsBasin,
hiddenPipe: () => culvertHiddenPipe,
inletOptions: () => culvertInletOptions,
extraState: () => culvertExtraState,
basinExtraState: () => culvertBasinExtraState,
activeRevet: () => activeRevet,
setActiveRevet: (key: RevetKey | null) => {
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) => panel.show(visible ? activeRevet : null);
showRevetControl = (visible) => {
const key = visible ? activeRevet : null;
panel.show(key);
dockPanel.show(key);
if (key) adoptAdjustPanel(dockPanel.root);
else releaseAdjustPanel(dockPanel.root);
};
showRevetControl(activeRevet !== null);
// 세월교 조정창 — 배수관 창과 조작 축이 달라 따로 만든다(2026-08-25 사용자).
// 우측 상단 줌 버튼이 원배율에서 표시 반폭까지 다룬다(하단 ◀/▶/↺ 폐지, 2026-08-23).
+47 -25
View File
@@ -24,6 +24,7 @@ import type {
import { buildBoxPanel } from "./B06_Section_UI_Cross_Box_Panel";
import type { BoxControl, BoxPanelHandle } from "./B06_Section_UI_Cross_Box_Panel";
import { boxPanelDeps, structureToggle } from "./B06_Section_UI_Cross_View_Box";
import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Dock";
export interface BodyWiringDeps {
section: CrossSection;
@@ -60,39 +61,53 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring {
let fordHeights = new Map<FordWallRole, number>();
let boxLayout: BoxLayout | null = null;
const fordPanel =
const fordDeps =
section.ford && ford
? buildFordPanel(
fordPanelDeps({
section,
ford,
heightFor: (role) => fordHeights.get(role) ?? 0,
slabLengthM: () => fordSlabLengthM,
close: () => {
if (fordRole) toggleFord(fordRole);
},
}),
)
? fordPanelDeps({
section,
ford,
heightFor: (role) => fordHeights.get(role) ?? 0,
slabLengthM: () => fordSlabLengthM,
close: () => {
if (fordRole) toggleFord(fordRole);
},
})
: null;
const boxPanel =
const boxDeps =
section.box && box
? buildBoxPanel(
boxPanelDeps({
chainageM: chainage,
box,
layout: () => boxLayout,
close: () => {
if (boxRole) toggleBox(boxRole);
},
}),
)
? boxPanelDeps({
chainageM: chainage,
box,
layout: () => boxLayout,
close: () => {
if (boxRole) toggleBox(boxRole);
},
})
: null;
const fordPanel = fordDeps ? buildFordPanel(fordDeps) : null;
const boxPanel = boxDeps ? buildBoxPanel(boxDeps) : null;
// 좌측 [횡단 조정] 사본 — 배수관 조정창과 같은 규칙(2026-08-29 일원화): 같은 deps로
// 하나 더 만들어 고른 동안만 좌측 슬롯에 세운다.
const fordDock = fordDeps ? buildFordPanel(fordDeps, { dock: true }) : null;
const boxDock = boxDeps ? buildBoxPanel(boxDeps, { dock: true }) : null;
const syncDock = (
dock: FordPanelHandle | BoxPanelHandle | null,
role: FordWallRole | BoxSideRole | null,
): void => {
if (!dock) return;
(dock as { show: (role: FordWallRole | BoxSideRole | null) => void }).show(role);
if (role) adoptAdjustPanel(dock.root);
else releaseAdjustPanel(dock.root);
};
const toggleFord = structureToggle<FordWallRole>({
current: () => fordRole,
setCurrent: (value) => (fordRole = value),
setActive: (value) => setFordActive(value),
showPanel: (visible) => fordPanel?.show(visible ? fordRole : null),
showPanel: (visible) => {
fordPanel?.show(visible ? fordRole : null);
syncDock(fordDock, visible ? fordRole : null);
},
persist: (value) => ford?.select(chainage, value),
isCardSelected: deps.isCardSelected,
clearArea: deps.clearArea,
@@ -102,7 +117,10 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring {
current: () => boxRole,
setCurrent: (value) => (boxRole = value),
setActive: (value) => setBoxActive(value),
showPanel: (visible) => boxPanel?.show(visible ? boxRole : null),
showPanel: (visible) => {
boxPanel?.show(visible ? boxRole : null);
syncDock(boxDock, visible ? boxRole : null);
},
persist: (value) => box?.select(chainage, value),
isCardSelected: deps.isCardSelected,
clearArea: deps.clearArea,
@@ -111,6 +129,8 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring {
fordPanel?.show(fordRole);
boxPanel?.show(boxRole);
syncDock(fordDock, fordRole);
syncDock(boxDock, boxRole);
return {
fordAdjust: () => ford?.adjustFor(chainage),
@@ -125,12 +145,14 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring {
if (fordRole) setter(fordRole);
// 창은 그리기 전에 만들어져 값이 비어 있다 — 계산 결과가 들어온 뒤 다시 렌더한다.
fordPanel?.show(fordRole);
syncDock(fordDock, fordRole);
},
attachBox: (setter, layout) => {
setBoxActive = setter;
boxLayout = layout;
if (boxRole) setter(boxRole);
boxPanel?.show(boxRole);
syncDock(boxDock, boxRole);
},
clearSelection: () => {
if (fordRole !== null) toggleFord(fordRole);
+34 -3
View File
@@ -44,6 +44,10 @@ import { computeHaulPlan } from "@util/common_util_mass_haul_balance";
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
import { staleDesignChainages } from "./B06_Section_UI_Section_Common";
import { createStandardPanel, type StandardPanelController } from "./B06_Section_UI_Standard_Panel";
import {
createB06StructuresPanel,
wireStructureSelection,
} from "./B06_Section_UI_Page_Structures_Panel";
import "./B06_Section_UI_Style.css";
import "./B06_Section_UI_Style_Cross.css";
import "./B06_Section_UI_Style_Cross_Controls.css";
@@ -115,13 +119,36 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
});
confirmButton.disabled = true;
const actionRow = document.createElement("div");
// 사이드 최하단 고정(공용 ui-sidebar-actions) — 스크롤에서 제외(2026-08-05 사용자 지시).
actionRow.className = "b06-profile__actions ui-sidebar-actions";
actionRow.className = "b06-profile__actions";
actionRow.append(goProfileButton, saveButton, confirmButton);
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤 — 가장 가까운 카드로 간다. */
const focusStationAt = (chainageM: number): void => {
const sections = sectionDetail?.cross_sections;
if (!sections?.length) return;
const nearest = sections.reduce((best, entry) =>
Math.abs(entry.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM) ? entry : best,
);
sectionView.focusStation(nearest.station_id);
};
// 「구조물 배치」 — B05와 같은 컨테이너·하단 목록 템플릿(2026-08-29 일원화).
// 하단 고정 dock 도 B05와 같은 구조: [구조물 목록][구분선][액션 버튼 행].
const structuresPanel = createB06StructuresPanel({
projectId,
stationInterval: () => stationInterval ?? 20,
focusChainage: focusStationAt,
});
const dockDivider = document.createElement("hr");
dockDivider.className = "b05-structure__divider";
const actionDock = document.createElement("div");
actionDock.className = "b05-route__dock ui-sidebar-actions";
actionDock.append(structuresPanel.listRoot, dockDivider, actionRow);
const leftForm = document.createElement("div");
leftForm.className = "b06-profile__form";
leftForm.append(viewGroup, standardGroup, actionRow);
// 순서: 보기 반폭 → 구조물 배치(표준 횡단면 위 — 2026-08-29 사용자 확정) → 표준.
leftForm.append(viewGroup, structuresPanel.root, standardGroup, actionDock);
// 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님.
attachCollapsible(leftForm);
@@ -437,6 +464,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
stationControls.ford,
stationControls.box,
);
// 횡단도 벽·구체 선택 → 좌측 「구조물 배치」 폼에 그 시설 로드(2026-08-29 일원화).
wireStructureSelection(stationControls, () => sectionDetail, structuresPanel);
// 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다.
const mainArea = document.createElement("div");
@@ -659,6 +688,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
currentRouteId = context.route_id;
loadRockOffsets();
stationControls.load();
// 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다.
void structuresPanel.load();
try {
const existing = await getSections(projectId, context.route_id);
if (!existing.longitudinal) {
@@ -0,0 +1,156 @@
/* =============================================================================
* B06_Section_UI_Page_Structures_Panel.ts
* B06 좌측 「구조물 배치」 — B05와 **같은 컨테이너·목록 템플릿**을 세운다
* (2026-08-29 사용자: B05/B06 인터페이스 일원화. 페이지 본체는 700줄 제한으로
* 여기서 조립만 받아 간다).
*
* · 폼·목록 구현 = 공용 진입점(`A00_Common/b_structures_section`) — B05 그대로.
* · 구조물(B~G군) 추가·수정·삭제는 세션 미저장분(`writePendingStructures`)에 쌓고
* [임시저장]·[확정]의 `flushPendingStructures`가 정본에 쓴다(캐시→저장 원칙).
* · 계곡 통과 시설(A군, pipe_points 정본)은 **표시·선택만** — 추가·이동·삭제는
* 배수유역 재분할 체인이 있는 B05 몫이라 안내만 한다(2026-08-29 1차 범위).
* · 목록·폼 선택 → 해당 측점 카드 선택·스크롤. 횡단도 벽·구체 선택 → 폼에 그 시설
* 로드(소유 측점 기준). 하위 [횡단 조정] 컨테이너는 Adjust_Dock이 맡는다.
* ========================================================================== */
import { createStructuresSection, type PipeFacilityItem } from "../A00_Common/b_structures_section";
import {
fetchStructures,
fetchStructureTypes,
readPendingStructures,
structureAnchorM,
writePendingStructures,
type StructureInstance,
} from "../B05_Profile/B05_Profile_Api_Structures";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { showToast } from "@ui/ui_template_elements";
import { adjustDockRoot, resetAdjustDock } from "./B06_Section_UI_Adjust_Dock";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
const PIPE_GUIDE = "계곡 통과 시설의 추가·이동·삭제는 B05(종단) 화면에서 합니다.";
export interface B06StructuresPanelDeps {
projectId: string | null;
/** 측점 간격(m) — 측점번호+잔여거리 환산용. */
stationInterval: () => number;
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다. */
focusChainage: (chainageM: number) => void;
}
export interface B06StructuresPanel {
/** 「구조물 배치」 섹션(하위 [횡단 조정] 포함) — 좌측 패널에 붙인다. */
root: HTMLElement;
/** 배치된 구조물 목록 — 하단 고정 dock에 붙인다(B05와 같은 자리). */
listRoot: HTMLElement;
/** 타입 레지스트리·구조물 정본(미저장분 우선)·관 지점을 받아 채운다. */
load: () => Promise<void>;
/** 횡단도에서 고른 벽·구체의 시설을 폼에 올린다(null = 해제). */
showPipeAt: (chainageM: number | null) => void;
}
export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06StructuresPanel {
let structures: StructureInstance[] = [];
/** 새 항목에 식별자를 미리 붙인다 — 저장 전에도 목록에서 고르고 지울 수 있어야
* 하고, 서버는 빈 값일 때만 새로 발급한다(B05 다리와 같은 규칙). */
const withLocalIds = (next: StructureInstance[]): StructureInstance[] =>
next.map((item) =>
item.structure_id ? item : { ...item, structure_id: crypto.randomUUID().replace(/-/g, "") },
);
const section = createStructuresSection({
onChange: (next) => {
structures = withLocalIds(next);
section.setStructures(structures);
if (deps.projectId) writePendingStructures(deps.projectId, structures);
},
onSelect: (structure) => {
if (structure) deps.focusChainage(structureAnchorM(structure));
},
getInterval: deps.stationInterval,
onPipeAdd: () => showToast(PIPE_GUIDE, "error"),
onPipeUpdate: () => showToast(PIPE_GUIDE, "error"),
onPipeRemove: () => showToast(PIPE_GUIDE, "error"),
onPipeSelect: (chainageM) => {
if (chainageM !== null) deps.focusChainage(chainageM);
},
});
// 하위 [횡단 조정] 컨테이너 — 폼 본문 맨 아래, 기존 항목과 경계 구분(2026-08-29
// 사용자: 병합 항목은 하위 컨테이너로 담아 혼동 방지).
resetAdjustDock();
section.root.querySelector(".b05-route__panel-body")?.append(adjustDockRoot());
async function load(): Promise<void> {
if (!deps.projectId) return;
const projectId = deps.projectId;
try {
const [types, stored, pipeResponse] = await Promise.all([
fetchStructureTypes(),
fetchStructures(projectId),
fetchDetailPipePoints(projectId),
]);
section.setTypes(types);
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙).
structures = readPendingStructures(projectId) ?? stored.structures;
section.setStructures(structures);
const pipes: PipeFacilityItem[] = pipeResponse.pipe_points.map((pipe) => ({
chainage_m: pipe.chainage_m,
facility: pipe.facility ?? "pipe",
start_m: pipe.start_m,
end_m: pipe.end_m,
source: pipe.source,
options: pipe.options,
design_flow_m3s: null,
}));
section.setPipeFacilities(pipes);
} catch (error) {
showToast(
error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.",
"error",
);
}
}
return {
root: section.root,
listRoot: section.listRoot,
load,
showPipeAt: (chainageM) => section.selectPipeByChainage(chainageM),
};
}
/**
* 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·BOX 선택이 일어나면 그
* 구조물의 **소유 측점** 시설을 좌측 폼에 올린다(2026-08-29 일원화 3단계).
* 제어 객체의 select를 감싸기만 한다 — 조작·저장 경로는 그대로다.
*/
export function wireStructureSelection(
stationControls: StationControls,
detail: () => SectionDetailResponse | null,
panel: B06StructuresPanel,
): void {
const ownerChainageOf = (chainageM: number): number => {
const sectionAt = detail()?.cross_sections.find(
(entry) => Math.abs(entry.chainage_m - chainageM) < 0.01,
);
return (sectionAt && stationControls.structureSpan.ownerOf(sectionAt)?.chainage_m) ?? chainageM;
};
const origRevet = stationControls.revetOffset.select;
stationControls.revetOffset.select = (chainageM, key) => {
origRevet(chainageM, key);
panel.showPipeAt(key ? ownerChainageOf(chainageM) : null);
};
const origFord = stationControls.ford.select;
stationControls.ford.select = (chainageM, role) => {
origFord(chainageM, role);
panel.showPipeAt(role ? chainageM : null);
};
const origBox = stationControls.box.select;
stationControls.box.select = (chainageM, role) => {
origBox(chainageM, role);
panel.showPipeAt(role ? chainageM : null);
};
}
@@ -112,6 +112,9 @@ export interface SectionViewController {
) => void;
/** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */
refreshCard: (chainageM: number) => void;
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */
focusStation: (stationId: string) => void;
clear: () => void;
dispose: () => void;
}
@@ -701,6 +704,10 @@ export function createSectionView(
if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(root));
},
refreshCard,
focusStation(stationId) {
if (selectedStationId !== stationId) selectStation(stationId, true);
else revealCard(stationId, "smooth");
},
clear() {
currentDetail = null;
selectedStationId = null;
@@ -227,3 +227,45 @@
color: var(--color-text);
background: var(--color-surface);
}
/* ── 좌측 [횡단 조정] 사본(2026-08-29 B05/B06 일원화) ─────────────────────
* 오버레이 창과 같은 DOM을 좌측 사이드바에 흐름 배치로 세운다. */
.b06-structure-panel--dock {
position: static;
z-index: auto;
max-height: none;
margin-top: var(--spacing-8);
border-color: var(--color-border);
overflow: visible;
}
/* 9키·십자 위치제어(이동 D-pad·집수정 9키)는 횡단도 오버레이 전용
* (2026-08-29 사용자 지시 7) — 사본에서는 그 행을 통째로 숨긴다. */
.b06-structure-panel--dock
.b06-structure-panel__struct:has(> .b06-structure-panel__controls.b06-structure-panel__buttons),
.b06-structure-panel--dock
.b06-structure-panel__struct:has(
> .b06-structure-panel__controls.b06-structure-panel__basin-buttons
) {
display: none;
}
/* [횡단 조정] 하위 컨테이너 — 구조물 배치 폼과 경계 구분(접이식). */
.b06-adjust-dock {
margin-top: var(--spacing-8);
padding-top: var(--spacing-8);
border-top: 1px solid var(--color-border);
}
.b06-adjust-dock > summary {
color: var(--color-text-body);
font-size: var(--text-caption);
font-weight: var(--font-weight-medium);
cursor: pointer;
}
.b06-adjust-dock__empty {
margin: var(--spacing-8) 0 0;
color: var(--color-text-muted);
font-size: var(--text-caption);
}