Files
Aislo/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts
T
eomsangdonandClaude Opus 5 f6296fa7c5 feat(B05·B06): 구조물 3D 투영 표기 · 직교 카메라 · 조정창 템플릿 일원화
3D 구조물과 성토면의 관계를 "잘라내기"에서 "투영 표기"로 바꾸고, 세월교·BOX암거
조정창을 기슭막이 템플릿으로 통일했다.

B05 코리도
- 구조물 탑뷰 투영 산출(_Corridor_Carve.ts 신규): 구조물 솔리드 링 사이 실좌표
  사각형 + 변형 성토면 영역. 노선 파라미터가 아니라 행 횡단선과 실좌표로 교차시켜
  곡선 구간·날개벽 벌어짐이 그대로 잡힌다.
- 사각형 합집합 경계(_Corridor_Union.ts 신규): 변을 교점에서 토막 내고 내부·공유
  토막을 버려 최외곽 윤곽만 남긴다(측정 133 -> 13 루프).
- 표기 모드(CARVE_APPLY=false): 서피스를 자르지 않고 ① 성토면 위 절단 투영선
  ② 원지반 최고 표고 위 Z평면에 구조물 윤곽(노랑)과 유입·유출 성토부 윤곽
  (하늘·주황)을 따로 그린다. 구조물 커브는 성토면 없다고 보고, 성토부 커브는
  구조물 없다고 보고 만든다.
- 구조물 실루엣 공용화(_Corridor_Station_Structure.ts 신규): 기슭막이·BOX암거·
  세월교 실루엣을 한 자료형으로. 성토면 강제 변형은 스위치로 끄고
  (STRUCTURE_SILHOUETTE_OVERRIDE), 노견 확장만 상시 적용(shoulderSpans).
- 평면 영역 분리(_Corridor_Region.ts), 절단기 일반화(clipGeometry가 영역 배열·
  전체 정점 속성 보간 — 리본 UV 유지). 지형은 코리도 스트립만 도려낸다.
- BOX암거 3D를 측점 프레임 직선 압출로(노선 추종 금지), 날개벽 계산 공용화
  (_Corridor_Structures_Wing.ts 신규) 후 세월교에도 날개벽 4매 추가.
- 세월교 보어를 관 련수만큼 뚫고, 보어 밴드를 단면 표고로 클램프해 구멍 찌그러짐
  제거. 날개벽 뿌리를 벽 외측면 모서리로 교정.
- 직교 카메라(_Viewer_Camera.ts 신규) — 원근 왜곡 없이 탑뷰 판독. 커서 피벗 줌이
  직교에서 camera.zoom을 함께 조인다.

B06 횡단
- 조정창 공용 부품(_Cross_Panel_Base.ts 신규): 머리줄·2행 항목·D-pad·키보드
  방향키. 세월교·BOX 조정창을 같은 템플릿으로 옮기고 십자 세트를 창 맨 아래로.
- 세월교 조정창에 날개벽 항목 추가(설치·짧은쪽 높이·길이·각도) — 정본
  pipe_points 되쓰기, 바닥판 연장은 길이×cos각으로 즉시 재계산.
- 세월교 좌우 이동을 집수정 규칙으로: 노견 수평 확장 + 상하 이동분만 성토선.

BUILD_VERSION 19 -> 31(저장 코리도 캐시 무효화). typecheck·prettier 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:03:42 +09:00

567 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B06_Section_UI_Cross_Structure_Panel.ts
* 횡단도 안에 뜨는 **구조물 조정 오버레이 창**(2026-08-21 사용자 확정).
*
* 조정 조작구는 카드 하단이 아니라 **해당 횡단도 안에** 있어야 한다 — 어느 도면의
* 구조물을 만지는지 눈으로 붙어 있어야 한다. 기슭막이는 4축(2026-08-22 확정):
* 좌우 ◀▶(노견/시작점 수평 연장, 물매 1:1.2 불변), 상하 ▲▼(성토선을 타는 대각),
* 높이 ±0.1m(재질 한계), 재질(메2.0/찰3.0/콘크리트5.0). 다단은 단 수 숫자++/-.
*
* 창은 그래프 영역(`.b06-cross-card__chart-wrap`) 안 **좌측 하단**에 얹힌다.
* ========================================================================== */
import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert";
import { materialLimit, REVET_MATERIALS } from "./B06_Section_UI_Cross_Culvert_Const";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import type { BasinAdjust, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import type { SpanValues } from "./B06_Section_UI_Cross_Culvert_Wire";
import { L } from "./B06_Section_UI_Section_Common";
import { makeButton, makeRow } from "./B06_Section_UI_Cross_Panel_Base";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
export interface StructurePanelDeps {
/** 지금 조작값(좌우 x·상하 d·높이 h·재질 m). 창을 열거나 값이 바뀔 때마다 읽는다. */
adjustFor: (key: RevetKey) => WallAdjust;
/** 좌우: 화면 좌(+)/우(−)로 미는 양(m). 부호 환산은 카드가 한다. */
nudge: (key: RevetKey, screenDeltaM: number) => void;
/** 상하: 성토선을 타는 대각 이동(수평 성분 m, + = 사면 아래). */
nudgeSlope: (key: RevetKey, deltaM: number) => void;
/** 높이 ±0.1m. 재질 한계 절삭·경고는 기하·카드가 한다. */
nudgeHeight: (key: RevetKey, deltaM: number) => void;
/** 실제 그려진 벽 높이(m) — 표시·높이 조작의 기준값. */
heightFor: (key: RevetKey) => number;
materialFor: (key: RevetKey) => RevetMaterial;
setMaterial: (key: RevetKey, material: RevetMaterial) => void;
/** 자동 자리로 되돌린다(4축 모두). */
reset: (key: RevetKey) => void;
/** 지금 관 길이(m) — 조정 단위가 관 길이 1m이라 창에 같이 적는다. */
pipeLengthM: () => number | null;
/** 창을 닫는다 = 구조물 선택 해제. */
close: () => void;
/** 유입측 구조물 형식(드롭다운 값 — 2026-08-22 사용자). */
structureFor: () => InletStructureChoice;
/** 유입측 구조물 형식 변경 — 카드를 다시 그린다. */
setStructure: (value: InletStructureChoice) => void;
basinAdjustFor: () => BasinAdjust;
updateBasin: (patch: Partial<BasinAdjust>) => void;
/** 집수정 좌우 — **화면 기준**(+ = 화면 왼쪽). 부호 환산·한계 토스트는 카드가 한다. */
nudgeBasinLateral: (screenDeltaM: number) => void;
resetBasin: () => void;
/** 집수정 계류측 다단 상태·단 수 지정(2026-08-22 — 유출측과 같은 체계). */
basinExtraState: () => { canAdd: boolean; count: number };
setBasinExtraCount: (count: number) => void;
/** 이동 조작 가능 여부 — 집수정(자리 고정)은 숨긴다. */
canNudge: (key: RevetKey) => boolean;
/** 상황에 안 맞는 선택지 숨김(2026-08-22 사용자) — 기하가 판정한 가용성. */
optionsFor: () => { revetAllowed: boolean; basinLUAllowed: boolean };
/** 다단 기슭막이 상태 — 유출 벽 선택 시 단 수 행 표시 판단에 쓴다. */
extraState: () => { canAdd: boolean; count: number };
/** 다단 기슭막이 단 수 지정 — 지형 허용보다 크면 기하가 자르고 토스트로 알린다. */
setExtraCount: (count: number) => void;
/** 다단 등간격 배치(2026-08-22 ①) — 사면 구간이 같아지게 단들을 재배치. */
equalizeExtras: () => void;
/** 이 구조물의 종방향 구간값(길이·전·후). 구간값이 없는 요소면 null(2026-08-24). */
spanFor: (key: RevetKey) => SpanValues | null;
/** 구간값 변경 — 링크 카드에서 만져도 **소유 측점** 값이 바뀐다. */
setSpan: (key: RevetKey, patch: Partial<SpanValues>) => void;
/** 이 카드가 연동으로 생긴 기슭막이인지 + 지금 연동 상태. 소유 측점이면 null. */
linkState: () => { linked: boolean } | null;
setLinked: (linked: boolean) => void;
/** 종단경사 반영 — 기슭막이 한 벌 전체 공통(소유 측점에 하나). */
followGrade: () => boolean;
setFollowGrade: (follow: boolean) => void;
/** 창 스크롤 자리를 카드별로 기억할 키(측점 id). 버튼을 누르면 카드가 다시 그려지고
* 창도 새로 만들어지므로, 이 키로 스크롤 자리를 되살린다(2026-08-24 사용자). */
scrollKey: string;
}
export interface StructurePanelHandle {
root: HTMLElement;
/** null이면 숨긴다. 값이 오면 그 구조물 기준으로 다시 그린다. */
show: (key: RevetKey | null) => void;
}
/**
* 좌우·상하 한 걸음(m) — **관 길이 1m**에 맞춘다(2026-08-21 사용자 확정).
* 높이는 0.1m 눈금(2026-08-22 사용자 확정 2~3m·0.5~3m 구간 0.1 단위 제어).
*/
const STEP_M = 1.0;
const HEIGHT_STEP_M = 0.1;
const BASIN_STEP_M = 0.1;
/** 전/후 한 걸음(m) — 집수정 기본이 2m·1/1이라 1m 눈금은 너무 거칠다. */
const SPAN_STEP_M = 0.5;
/** 길이 한 걸음(m) — **전/후 걸음의 2배**여야 한 번에 양쪽이 정확히 반씩 늘어난다.
* 0.5m로 두면 반쪽이 0.25m라 0.1m 눈금에 안 떨어지고 전/후가 어긋난다. */
const LENGTH_STEP_M = SPAN_STEP_M * 2;
/**
* 카드별 창 스크롤 자리. 조작 버튼을 누르면 카드가 통째로 다시 그려져 창이 새로
* 만들어진다 — 기억해 두지 않으면 매번 맨 위(제목)로 튄다(2026-08-24 사용자).
*/
const panelScrollTops = new Map<string, number>();
/** 구조물 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */
export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHandle {
const root = document.createElement("div");
root.className = "b06-structure-panel";
root.classList.add("is-hidden");
root.addEventListener("scroll", () => {
if (!root.classList.contains("is-hidden")) panelScrollTops.set(deps.scrollKey, root.scrollTop);
});
// 창 안에서의 클릭이 카드 선택으로 번지지 않게 한 번에 막는다.
root.addEventListener("click", (event) => event.stopPropagation());
const title = document.createElement("span");
title.className = "b06-structure-panel__title";
// 정보 표시(2026-08-22 사용자 — 항목당 한 행): 관 길이 / 좌우 / 상하.
const value = document.createElement("div");
value.className = "b06-structure-panel__value";
const pipeLine = document.createElement("span");
const lateralLine = document.createElement("span");
const slopeLine = document.createElement("span");
value.append(pipeLine, lateralLine, slopeLine);
let current: RevetKey | null = null;
const act = (run: (key: RevetKey) => void) => () => {
if (current) run(current);
};
const moveRow = makeRow(L("B06_Cross_Move_Label"));
// 버튼 묶음 클래스는 컨트롤 행에 — **십자(D-pad) 배치**(2026-08-22 사용자):
// ▲ 위 / ◀ ↺ ▶ 가운데 / ▼ 아래. 초기화가 십자 중앙이다.
moveRow.controls.classList.add("b06-structure-panel__buttons");
const buttons = moveRow.controls;
const dpad = (
label: string,
slot: "up" | "down" | "left" | "right" | "reset" | "equal",
title: string,
onClick: () => void,
): HTMLButtonElement => {
const button = makeButton(label, title, onClick);
button.classList.add(`b06-structure-panel__btn--${slot}`);
return button;
};
buttons.append(
dpad(
"▲",
"up",
L("B06_Cross_Revet_Up"),
act((key) => deps.nudgeSlope(key, -STEP_M)),
),
dpad(
"◀",
"left",
L("B06_Cross_Revet_Left"),
act((key) => deps.nudge(key, STEP_M)),
),
dpad(
"↺",
"reset",
L("B06_Cross_Revet_Reset"),
act((key) => deps.reset(key)),
),
dpad(
"▶",
"right",
L("B06_Cross_Revet_Right"),
act((key) => deps.nudge(key, -STEP_M)),
),
dpad(
"▼",
"down",
L("B06_Cross_Revet_Down"),
act((key) => deps.nudgeSlope(key, STEP_M)),
),
dpad("≡", "equal", L("B06_Cross_Extra_Equalize"), () => deps.equalizeExtras()),
);
// 재질 행(2026-08-22 사용자 ④ — 높이 행 **위**, 폭 축소: 한계는 툴팁으로).
const materialParts = makeRow(L("B06_Cross_Mat_Label"));
const materialRow = materialParts.row;
const materialSelect = document.createElement("select");
materialSelect.className = "b06-structure-panel__select";
const MATERIAL_LABEL: Record<RevetMaterial, string> = {
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);
});
materialParts.controls.append(materialSelect);
// 높이 행 — ±0.1m, 한계는 재질이 정한다.
const heightParts = makeRow(L("B06_Cross_Height_Label"));
const heightRow = heightParts.row;
const heightValue = document.createElement("span");
heightValue.className = "b06-structure-panel__hval";
const heightMinus = makeButton(
"",
L("B06_Cross_Height_Minus"),
act((key) => deps.nudgeHeight(key, -HEIGHT_STEP_M)),
);
const heightPlus = makeButton(
"",
L("B06_Cross_Height_Plus"),
act((key) => deps.nudgeHeight(key, HEIGHT_STEP_M)),
);
heightParts.controls.append(heightMinus, heightValue, heightPlus);
// 유입측 구조물 형식 드롭다운(2026-08-22 사용자) — 자동/기슭막이/집수정 I·ㄴ·ㄷ.
const structureParts = makeRow(L("B06_Cross_Struct_Label"));
const structureRow = structureParts.row;
const select = document.createElement("select");
select.className = "b06-structure-panel__select";
const OPTIONS: Array<[InletStructureChoice, string]> = [
["auto", L("B06_Cross_Struct_Auto")],
["revet", L("B06_Cross_Struct_Revet")],
["I", L("B06_Cross_Struct_I")],
["L", L("B06_Cross_Struct_L")],
["U", L("B06_Cross_Struct_U")],
];
for (const [optionValue, label] of OPTIONS) {
const option = document.createElement("option");
option.value = optionValue;
option.textContent = label;
select.append(option);
}
select.addEventListener("change", () => {
deps.setStructure(select.value as InletStructureChoice);
});
structureParts.controls.append(select);
// 집수정 조작 = **9키(3×3)**(2026-08-22 사용자 확정) — 십자 축은 이동, 네 모서리는
// 크기, 가운데는 초기화. 기슭막이 십자와 축 규칙이 같아 따로 익힐 게 없다.
// ⇕+ 사면▲ ⇔+
// ◀ ↺ ▶
// ⇕- 사면▼ ⇔-
const basinRow = makeRow("집수정 크기·위치");
basinRow.controls.classList.add("b06-structure-panel__basin-buttons");
const basinKey = (
label: string,
slot: string,
titleText: string,
onClick: () => void,
): HTMLButtonElement => {
const button = makeButton(label, titleText, onClick);
button.classList.add(`b06-structure-panel__btn--${slot}`);
return button;
};
const basinPatch = (patch: () => Partial<BasinAdjust>) => () => deps.updateBasin(patch());
basinRow.controls.append(
basinKey(
"⇕+",
"nw",
"내부 높이 +0.1m",
basinPatch(() => ({
innerHeightM: Math.min(2, deps.basinAdjustFor().innerHeightM + BASIN_STEP_M),
})),
),
basinKey(
"▲",
"up",
"1:1.2 성토선을 따라 위로 0.1m",
basinPatch(() => ({ slopeM: Math.max(0, deps.basinAdjustFor().slopeM - BASIN_STEP_M) })),
),
basinKey(
"⇔+",
"ne",
"내부 폭 +0.1m",
basinPatch(() => ({
innerWidthM: Math.min(2, deps.basinAdjustFor().innerWidthM + BASIN_STEP_M),
})),
),
basinKey("◀", "left", L("B06_Cross_Revet_Left"), () => deps.nudgeBasinLateral(BASIN_STEP_M)),
basinKey("↺", "reset", L("B06_Cross_Revet_Reset"), () => deps.resetBasin()),
basinKey("▶", "right", L("B06_Cross_Revet_Right"), () => deps.nudgeBasinLateral(-BASIN_STEP_M)),
basinKey(
"⇕-",
"sw",
"내부 높이 -0.1m",
basinPatch(() => ({
innerHeightM: Math.max(1, deps.basinAdjustFor().innerHeightM - BASIN_STEP_M),
})),
),
basinKey(
"▼",
"down",
"1:1.2 성토선을 따라 아래로 0.1m",
basinPatch(() => ({ slopeM: deps.basinAdjustFor().slopeM + BASIN_STEP_M })),
),
basinKey(
"⇔-",
"se",
"내부 폭 -0.1m",
basinPatch(() => ({
innerWidthM: Math.max(1, deps.basinAdjustFor().innerWidthM - BASIN_STEP_M),
})),
),
);
// 집수정 계류측 다단 기슭막이 단 수 — 유출측 행과 같은 형식([- 값 +]).
const basinExtraParts = makeRow("집수정 계류측 기슭막이(단)");
const basinExtraRow = basinExtraParts.row;
const basinCountValue = document.createElement("span");
basinCountValue.className = "b06-structure-panel__hval";
let basinCount = 0;
const clampBasinCount = (value: number): number => Math.max(0, Math.min(9, Math.round(value)));
basinExtraParts.controls.append(
makeButton("", L("B06_Cross_Extra_Remove"), () =>
deps.setBasinExtraCount(clampBasinCount(basinCount - 1)),
),
basinCountValue,
makeButton("", L("B06_Cross_Extra_Add"), () =>
deps.setBasinExtraCount(clampBasinCount(basinCount + 1)),
),
);
// 다단 기슭막이 단 수(2026-08-22 사용자) — 높이 행과 같은 형식(- 값 +),
// 직접 입력 없음. 현재 단 수는 show()가 채운다.
const extraParts = makeRow(L("B06_Cross_Extra_Count"));
const extraRow = extraParts.row;
const countValue = document.createElement("span");
countValue.className = "b06-structure-panel__hval";
let currentCount = 0;
const clampCount = (value: number): number => Math.max(0, Math.min(9, Math.round(value)));
const countMinus = makeButton("", L("B06_Cross_Extra_Remove"), () =>
deps.setExtraCount(clampCount(currentCount - 1)),
);
const countPlus = makeButton("", L("B06_Cross_Extra_Add"), () =>
deps.setExtraCount(clampCount(currentCount + 1)),
);
extraParts.controls.append(countMinus, countValue, countPlus);
// 종방향 구간값(2026-08-24 사용자) — 길이·기준측점 전·후. 세 값이 곧 이 구조물이
// 덮는 범위이고, 그 범위에 걸린 옆 측점에 기슭막이가 자동으로 선다. 길이를 바꾸면
// 전/후가 지금 비율대로 따라가고, 전·후를 바꾸면 길이는 둘의 합이 된다.
let currentSpan: SpanValues | null = null;
const spanRows: Array<{ row: HTMLElement; value: HTMLElement; field: keyof SpanValues }> = [];
const makeSpanRow = (label: string, field: keyof SpanValues): void => {
const parts = makeRow(label);
const readout = document.createElement("span");
readout.className = "b06-structure-panel__hval";
const stepM = field === "lengthM" ? LENGTH_STEP_M : SPAN_STEP_M;
const step = (delta: number): void => {
if (!current || !currentSpan) return;
const next = Math.max(0, Math.round((currentSpan[field] + delta) * 10) / 10);
deps.setSpan(current, { [field]: next } as Partial<SpanValues>);
};
parts.controls.append(
makeButton("", `${label} ${stepM}m 줄이기`, () => step(-stepM)),
readout,
makeButton("", `${label} ${stepM}m 늘리기`, () => step(stepM)),
);
spanRows.push({ row: parts.row, value: readout, field });
};
makeSpanRow("길이", "lengthM");
makeSpanRow("기준측점 전", "beforeM");
makeSpanRow("기준측점 후", "afterM");
// 연동 기슭막이 옵션 2가지(2026-08-24 사용자) — **버튼 두 개를 한 행**에 둔다.
// 연동은 측점별, 경사(종단경사 반영)는 기슭막이 한 벌 전체 공통이다.
const optionParts = makeRow("옵션");
optionParts.controls.classList.add("b06-structure-panel__toggles");
const makeToggle = (label: string, title: string, onToggle: (on: boolean) => void) => {
const button = document.createElement("button");
button.type = "button";
button.className = "b06-structure-panel__btn b06-structure-panel__toggle";
button.textContent = label;
button.title = title;
let on = true;
button.addEventListener("click", (event) => {
event.stopPropagation();
on = !on;
button.classList.toggle("is-on", on);
button.setAttribute("aria-pressed", String(on));
onToggle(on);
});
const set = (value: boolean): void => {
on = value;
button.classList.toggle("is-on", value);
button.setAttribute("aria-pressed", String(value));
};
optionParts.controls.append(button);
return { button, set };
};
const linkToggle = makeToggle(
"연동",
"옆 측점 기슭막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다.",
(on) => deps.setLinked(on),
);
const gradeToggle = makeToggle(
"경사",
"종단경사 반영(3D 전용) — 켜면 3D 기슭막이가 종단경사를 따라 서고, 끄면 연장 구간 내내 Z를 동일하게 본다. 횡단도는 언제나 그 측점 계획고로 그린다.",
(on) => deps.setFollowGrade(on),
);
// 키보드 방향키 = 십자 버튼(2026-08-23 사용자). 9키(집수정)도 **십자 축만** —
// 모서리(크기) 키는 대상 아님. 셀렉트 포커스 중엔 드롭다운 탐색에 양보한다.
// 창은 카드마다 새로 만들어지므로 문서 리스너는 show/hide에 맞춰 붙였다 떼고,
// 카드가 통째로 사라진 경우(root 분리)엔 스스로 해제한다.
const ARROW_SLOT: Record<string, string> = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
};
const onArrowKey = (event: KeyboardEvent) => {
if (!root.isConnected) {
document.removeEventListener("keydown", onArrowKey);
return;
}
const slot = ARROW_SLOT[event.key];
if (!slot || root.classList.contains("is-hidden")) return;
const focus = event.target as HTMLElement | null;
if (focus && (focus.tagName === "SELECT" || focus.tagName === "INPUT")) return;
for (const candidate of [basinRow, moveRow]) {
if (candidate.row.classList.contains("is-hidden")) continue;
const button = candidate.controls.querySelector<HTMLButtonElement>(
`.b06-structure-panel__btn--${slot}`,
);
if (button) {
event.preventDefault();
button.click();
return;
}
}
};
const closeButton = makeButton("✕", L("B06_Cross_Revet_Close"), () => deps.close());
closeButton.classList.add("b06-structure-panel__close");
const head = document.createElement("div");
head.className = "b06-structure-panel__head";
const basinReset = makeButton("↺", "집수정 기본값", () => deps.resetBasin());
basinReset.classList.add("b06-structure-panel__basin-reset");
head.append(title, basinReset, closeButton);
root.append(
head,
value,
structureRow,
basinExtraRow,
materialRow,
heightRow,
extraRow,
...spanRows.map((entry) => entry.row),
optionParts.row,
// 십자(기슭막이)·9키(집수정) 조작 세트는 **패널 맨 아래**에 모은다(2026-08-24
// 사용자). 둘은 동시에 뜨지 않으므로 어느 쪽이 보이든 같은 자리에 온다.
basinRow.row,
moveRow.row,
);
return {
root,
show: (key) => {
current = key;
root.classList.toggle("is-hidden", key === null);
document.removeEventListener("keydown", onArrowKey);
if (!key) return;
document.addEventListener("keydown", onArrowKey);
const isExtra = key.startsWith("extra");
const movable = deps.canNudge(key);
const structure = deps.structureFor();
const isBasin =
key === "inlet" && (structure === "I" || structure === "L" || structure === "U");
title.textContent = isExtra
? `${L("B06_Cross_Revet_Extra")} ${Number(key.slice(5)) + 1}`
: key === "outlet"
? L("B06_Cross_Revet_Outlet")
: movable
? L("B06_Cross_Revet_Inlet")
: L("B06_Cross_Struct_InletBasin");
// 형식 선택은 유입측에서만, 이동·높이 조작은 기슭막이(집수정 제외)만.
structureRow.classList.toggle("is-hidden", key !== "inlet");
if (key === "inlet") {
// 상황에 안 맞는 선택지는 숨긴다(2026-08-22 사용자). 단 지금 선택된 값은
// 남긴다 — 숨기면 셀렉트가 빈 값이 된다.
const allow = deps.optionsFor();
for (const option of select.options) {
const optionValue = option.value as InletStructureChoice;
const hidden =
(optionValue === "revet" && !allow.revetAllowed) ||
((optionValue === "L" || optionValue === "U") && !allow.basinLUAllowed);
option.hidden = hidden && optionValue !== structure;
}
select.value = structure;
}
moveRow.row.classList.toggle("is-hidden", !movable);
basinRow.row.classList.toggle("is-hidden", !isBasin);
basinReset.classList.toggle("is-hidden", !isBasin);
const basinExtra = deps.basinExtraState();
basinExtraRow.classList.toggle(
"is-hidden",
!(isBasin && (basinExtra.canAdd || basinExtra.count > 0)),
);
basinCount = basinExtra.count;
basinCountValue.textContent = `${basinExtra.count}단`;
// I형은 내공(높이·폭) 개념이 없다 — 9키의 **네 모서리**만 숨긴다.
const isI = structure === "I";
for (const slot of ["nw", "ne", "sw", "se"]) {
basinRow.controls
.querySelector(`.b06-structure-panel__btn--${slot}`)
?.classList.toggle("is-hidden", isI);
}
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);
}
// 단 수 행은 유출 벽에서 **항상** 보인다(2026-08-22 사용자 ⑤ — 상태 따라
// 나타났다 사라지면 레이아웃이 널뛴다).
const extra = deps.extraState();
extraRow.classList.toggle("is-hidden", key !== "outlet");
currentCount = extra.count;
countValue.textContent = `${extra.count}단`;
// 구간값 — 있는 요소(유입·유출 기슭막이, 유입 집수정)에서만 세 행이 뜬다.
// 다단(extra·bextra)은 소유 벽 연장을 그대로 따르는 파생물이라 대상이 아니다.
currentSpan = deps.spanFor(key);
for (const entry of spanRows) {
entry.row.classList.toggle("is-hidden", currentSpan === null);
if (currentSpan) entry.value.textContent = `${currentSpan[entry.field].toFixed(1)}m`;
}
// 연동은 링크 카드에서만, 경사는 구간값이 있는 구조물에서만. 둘 다 없으면 행 자체를 숨긴다.
const linkState = deps.linkState();
linkToggle.button.classList.toggle("is-hidden", linkState === null);
linkToggle.set(linkState?.linked ?? true);
gradeToggle.button.classList.toggle("is-hidden", currentSpan === null);
gradeToggle.set(deps.followGrade());
optionParts.row.classList.toggle("is-hidden", linkState === null && currentSpan === null);
const adjust = deps.adjustFor(key);
// 정보는 항목당 한 행(2026-08-22 사용자) — 0이면 "자동", 좌우는 안/바깥,
// 상하는 위/아래로 적는다.
const pipeLength = deps.pipeLengthM();
pipeLine.classList.toggle("is-hidden", pipeLength === null);
pipeLine.textContent =
pipeLength === null ? "" : `${L("B06_Cross_Revet_Pipe")} ${pipeLength}m`;
lateralLine.textContent =
`${L("B06_Cross_Lateral_Label")} ` +
(Math.abs(adjust.x) > 1e-9
? `${L(adjust.x > 0 ? "B06_Cross_Revet_Outward" : "B06_Cross_Revet_Inward")} ${Math.abs(adjust.x).toFixed(1)}m`
: L("B06_Cross_Revet_Auto"));
// d = null → 자동(기본 자리). 숫자는 성토선 0점(벽 상단=노견) 기준 절대값
// (배관 벽 — 2026-08-23). 0 = 성토선 0 지점.
slopeLine.textContent =
`${L("B06_Cross_Slope_Label")} ` +
(adjust.d == null
? L("B06_Cross_Revet_Auto")
: Math.abs(adjust.d) > 1e-9
? `${L(adjust.d > 0 ? "B06_Cross_Revet_DirDown" : "B06_Cross_Revet_DirUp")} ${Math.abs(adjust.d).toFixed(1)}m`
: "0m");
// 행 표시가 다 끝난 뒤에 되살린다 — 숨김 토글로 창 높이가 바뀌기 전에 넣으면
// 브라우저가 잘라 버린다.
const saved = panelScrollTops.get(deps.scrollKey);
if (saved) root.scrollTop = saved;
},
};
}