Files
Aislo/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts
T
eomsangdon 2e28af6e86 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 통과.
2026-08-29 14:20:49 +09:00

619 lines
30 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 { REVET_FORMS, revetHeightLimit } 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;
/** 지금 그려진 벽의 **형태**(B05 폼과 같은 목록). */
formFor: (key: RevetKey) => string;
setForm: (key: RevetKey, form: string) => 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;
/** 독립 기슭막이(관 숨김) 카드인가 — 유입·유출 벽이 같은 성토 로직이라 조정창도
* 좌우 같은 행을 준다(집수정 형식 숨김 · 유입측 단 수 행 표시 — 2026-08-29). */
hiddenPipe: () => boolean;
/** 이동 조작 가능 여부 — 집수정(자리 고정)은 숨긴다. */
canNudge: (key: RevetKey) => boolean;
/** 좌우·상하 걸음이 0.1m인가(독립·추가 기슭막이). 배관 기준벽은 false = 1m. */
fineMove: (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;
/**
* 독립·추가 기슭막이의 좌우·상하 한 걸음(m) — **0.1m**(2026-08-29 사용자).
* 배관 기준벽(유입·유출)만 관 길이에 맞춘 1m 걸음을 그대로 쓴다.
*/
const OWN_STEP_M = 0.1;
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>();
/** 구조물 조정 오버레이 창을 만든다. 처음에는 숨어 있다.
* `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);
});
/**
* 기억해 둔 스크롤 자리를 되살린다. 카드를 다시 그릴 때 창은 **카드가 문서에
* 붙기 전에** 만들어지므로 그 순간의 대입은 레이아웃이 없어 먹지 않는다
* (2026-08-29 사용자: 값 조절만 해도 창이 제목 줄로 튀던 원인). 대입이 먹지
* 않았으면 붙은 다음 프레임에 한 번 더 넣는다.
*/
const restoreScroll = (): void => {
const saved = panelScrollTops.get(deps.scrollKey);
if (!saved) return;
root.scrollTop = saved;
if (root.scrollTop === saved) return;
requestAnimationFrame(() => {
if (root.isConnected && !root.classList.contains("is-hidden")) root.scrollTop = saved;
});
};
// 창 안에서의 클릭이 카드 선택으로 번지지 않게 한 번에 막는다.
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 moveStepOf = (key: RevetKey): number => (deps.fineMove(key) ? OWN_STEP_M : STEP_M);
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, -moveStepOf(key))),
),
dpad(
"◀",
"left",
L("B06_Cross_Revet_Left"),
act((key) => deps.nudge(key, moveStepOf(key))),
),
dpad(
"↺",
"reset",
L("B06_Cross_Revet_Reset"),
act((key) => deps.reset(key)),
),
dpad(
"▶",
"right",
L("B06_Cross_Revet_Right"),
act((key) => deps.nudge(key, -moveStepOf(key))),
),
dpad(
"▼",
"down",
L("B06_Cross_Revet_Down"),
act((key) => deps.nudgeSlope(key, moveStepOf(key))),
),
dpad("≡", "equal", L("B06_Cross_Extra_Equalize"), () => deps.equalizeExtras()),
);
// 형태 행(2026-08-22 사용자 ④ — 높이 행 **위**, 폭 축소: 한계는 툴팁으로).
// 선택지는 **B05 폼 형태 6종 그대로**다(2026-08-29 사용자). 종전에는 재질 3종
// (메/찰/콘크리트)만 보여 돌망태·통나무·바자를 고를 수 없었고, 조정창에서 만지면
// B05에서 고른 형태가 3종으로 뭉개졌다.
const materialParts = makeRow(L("B06_Cross_Mat_Label"));
const materialRow = materialParts.row;
const materialSelect = document.createElement("select");
materialSelect.className = "b06-structure-panel__select";
for (const form of REVET_FORMS) {
const option = document.createElement("option");
option.value = form;
option.textContent = form;
option.title = `${L("B06_Cross_Height_Label")} ~${revetHeightLimit(form).toFixed(1)}m`;
materialSelect.append(option);
}
materialSelect.addEventListener("change", () => {
if (current) deps.setForm(current, materialSelect.value);
});
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),
})),
),
);
// 다단 기슭막이 단 수(2026-08-22 사용자) — 높이 행과 같은 형식(- 값 +),
// 직접 입력 없음. 현재 단 수는 show()가 채운다.
//
// 벽 종류(배관 유입·유출, 독립, 집수정 계류측)를 가리지 않고 **행 하나**가 맡는다
// (2026-08-29 사용자: 좌·우 창의 행 순서가 갈리던 원인이 유입/유출 전용 행 둘이었다).
// 어느 저장 채널로 보낼지는 선택된 벽이 정한다 — 유출 벽은 `extras`(extra 키),
// 유입 벽·집수정 계류측은 `basinExtras`(bextra 키).
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;
/** 지금 창이 든 벽의 단 수 저장 채널. null이면 단 수 행을 숨긴다. */
let tierChannel: "outlet" | "basin" | null = null;
const clampCount = (value: number): number => Math.max(0, Math.min(9, Math.round(value)));
const setCount = (value: number): void => {
if (tierChannel === "outlet") deps.setExtraCount(clampCount(value));
else if (tierChannel === "basin") deps.setBasinExtraCount(clampCount(value));
};
const countMinus = makeButton("", L("B06_Cross_Extra_Remove"), () =>
setCount(currentCount - 1),
);
const countPlus = makeButton("", L("B06_Cross_Extra_Add"), () => setCount(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 LINK_TITLE =
"옆 측점 기슭막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다.";
/** 경사 해제(수평) 중에는 연동을 못 푼다 — 기슭막이 Z를 하나로 묶기 때문(2026-08-30). */
const LINK_LOCKED_TITLE =
"경사 해제(수평) 중에는 연동을 풀 수 없다 — 기슭막이 Z를 하나로 묶는다.";
const linkToggle = makeToggle("연동", LINK_TITLE, (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,
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;
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));
// 독립 기슭막이(구조물 정본 D군) — 배관 부속이 아니라 자리·높이만 만진다.
const isOwn = key === "own";
const movable = deps.canNudge(key);
const structure = deps.structureFor();
const isBasin =
key === "inlet" && (structure === "I" || structure === "L" || structure === "U");
// 독립 기슭막이(관 숨김)의 기준벽은 관이 없어 유입·유출이라는 이름이 안 맞는다 —
// 좌·우 어느 쪽이든 "기슭막이(독립)"으로 적는다(2026-08-29 사용자).
title.textContent =
isOwn || (deps.hiddenPipe() && (key === "inlet" || key === "outlet"))
? L("B06_Cross_Revet_Own")
: isExtra
? L("B06_Cross_Revet_Extra").replace("{n}", String(extraIndex + 1))
: key === "outlet"
? L("B06_Cross_Revet_Outlet")
: movable
? L("B06_Cross_Revet_Inlet")
: L("B06_Cross_Struct_InletBasin");
// 독립 기슭막이(관 숨김)는 유입·유출이 같은 성토 벽이다 — 집수정 형식 선택을
// 숨기고 유입측에도 단 수 행을 준다(2026-08-29 사용자: 좌우 조작 통일).
const ownTiers = deps.hiddenPipe() && key === "inlet";
// 형식 선택은 유입측에서만, 이동·높이 조작은 기슭막이(집수정 제외)만.
structureRow.classList.toggle("is-hidden", key !== "inlet" || deps.hiddenPipe());
if (key === "inlet" && !deps.hiddenPipe()) {
// 상황에 안 맞는 선택지는 숨긴다(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);
// 등간격(≡)은 **유출 채널 다단 전용**이다 — 유입 벽에서 누르면 남의 단을 옮긴다.
// 독립 기슭막이(옛 D경로 own)는 단 수·자리가 구조물 정본 몫이라 역시 숨긴다.
moveRow.controls
.querySelector(".b06-structure-panel__btn--equal")
?.classList.toggle("is-hidden", isOwn || key !== "outlet");
basinRow.row.classList.toggle("is-hidden", !isBasin);
basinReset.classList.toggle("is-hidden", !isBasin);
// 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 || isOwn);
heightRow.classList.toggle("is-hidden", !movable);
if (movable) {
heightValue.textContent = `${deps.heightFor(key).toFixed(1)}m`;
if (!isOwn) materialSelect.value = deps.formFor(key);
}
// 단 수 행 — 벽 종류를 가리지 않고 **높이 행 바로 아래** 한 자리에 온다.
// 유출 벽은 자기 채널(extra), 유입 벽·집수정 계류측은 basin 채널(bextra)을 쓴다.
// 유출 벽과 독립 기슭막이 유입 벽에서는 **항상** 보인다(2026-08-22 사용자 ⑤ —
// 상태 따라 나타났다 사라지면 레이아웃이 널뛴다). 집수정 계류측은 둘 자리가
// 있을 때만 뜬다(배관 유입 기슭막이는 다단 채널 자체가 없다).
tierChannel = key === "outlet" ? "outlet" : key === "inlet" ? "basin" : null;
const tiers =
tierChannel === "outlet"
? deps.extraState()
: tierChannel === "basin"
? deps.basinExtraState()
: { canAdd: false, count: 0 };
extraRow.classList.toggle(
"is-hidden",
!(
tierChannel === "outlet" ||
(tierChannel === "basin" && (ownTiers || (isBasin && (tiers.canAdd || tiers.count > 0))))
),
);
currentCount = tiers.count;
countValue.textContent = `${tiers.count}단`;
// 구간값 — 유입·유출 기슭막이, 유입 집수정, 그리고 **다단**(2026-08-29 사용자:
// 계곡부·능선부에서 단마다 연장이 달라 단별로 손수 넣는다)에서 세 행이 뜬다.
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);
// 경사 해제(수평) = 연동 잠김(2026-08-30 사용자). 버튼은 눌리지 않고 이유를 단다.
const levelled = !deps.followGrade();
linkToggle.button.disabled = levelled;
linkToggle.button.title = levelled ? LINK_LOCKED_TITLE : LINK_TITLE;
gradeToggle.button.classList.toggle("is-hidden", currentSpan === null);
gradeToggle.set(!levelled);
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");
// 행 표시가 다 끝난 뒤에 되살린다 — 숨김 토글로 창 높이가 바뀌기 전에 넣으면
// 브라우저가 잘라 버린다.
restoreScroll();
},
};
}