2026-08-29 사용자 지시(좌측 패널 항목 정리 — 배관 자리): - 길이·높이·기준측점 전/후 입력칸에 [-][+] 묶음 추가(stepper). 눈금은 조정창과 같다(길이 1m·전후 0.5m·높이 0.1m). 래퍼 여백 0이라 2열 격자는 그대로다. - 형태·구조물 형식 행은 [횡단 조정] 사본에서 숨김 — 폼의 형태·구조와 중복. - 단 수·옵션 행은 고른 벽 쪽(유입구/유출구) 그룹으로 DOM 이식. 배선을 옮기지 않아 값·동기는 그대로고, 사본이 갈릴 때마다 다시 이식한다. - 유입구 '구조' 드롭다운에 조정창 형식(자동·집수정 I/ㄴ/ㄷ)을 병합. 고른 값이 정본 둘(inlet_type·inlet_structure)을 함께 정하고, B06에서는 유입측 형식 제어와 양방향으로 맞춘다. 형태·형식 연결은 미연결 상태 그대로 둔다. 검증(공용 브라우저): 유입구 스텝 5·유출구 4개, 구조 목록 6종 병합, 벽 선택 시 단 수·옵션이 유출구 그룹으로 이동(dock에는 높이·구간값만 남음). B05도 동일 폼이 정상 표시되고 [횡단 조정]은 뜨지 않음. typecheck 통과.
635 lines
31 KiB
TypeScript
635 lines
31 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
// 좌측 유입구·유출구 폼의 "형태"와 중복 — 사본에서는 숨긴다(2026-08-29 지시 3).
|
||
materialRow.classList.add("b06-structure-panel__struct--form");
|
||
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;
|
||
// 유입구 "구조" 드롭다운으로 병합됐다 — 사본에서는 숨긴다(2026-08-29 지시 5).
|
||
structureRow.classList.add("b06-structure-panel__struct--inlet-kind");
|
||
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;
|
||
// 유입구·유출구 그룹으로 옮겨 붙는 행(2026-08-29 지시 4).
|
||
extraRow.classList.add("b06-structure-panel__struct--tier");
|
||
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("옵션");
|
||
// 단 수와 함께 유입구·유출구 그룹으로 옮겨 붙는다(2026-08-29 지시 4).
|
||
optionParts.row.classList.add("b06-structure-panel__struct--options");
|
||
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;
|
||
// 좌측 [횡단 조정] 사본의 단 수·옵션 행이 어느 그룹(유입구·유출구)으로 옮겨
|
||
// 붙을지 알린다(2026-08-29 지시 4). 다단 벽은 자기 채널 쪽으로 간다.
|
||
root.dataset.side =
|
||
tierChannel === "outlet" || key.startsWith("extra")
|
||
? "outlet"
|
||
: tierChannel === "basin" || key.startsWith("bextra")
|
||
? "inlet"
|
||
: "";
|
||
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();
|
||
},
|
||
};
|
||
}
|