Files
Aislo/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts
T
eomsangdonandClaude Opus 5 ff3e3b734b feat(B06): 기슭막이 4축 조작 — 좌우(노견 연장)·상하(사면 대각)·높이·재질
- 좌우 ◀▶ = 노견(추가 벽은 윗단 0.5m 기준선) 수평 연장 평행이동 —
  성토선 물매 1:1.2·상단 표고 불변, 지반에 못 닿으면 바닥 뜸 허용
- 상하 ▲▼ = 성토선을 타는 대각 이동(수평 성분 1m), 위 한계 =
  최소 성토고·토피(역경사), 추가 벽은 윗단 하단 관통 금지
- 높이 ±0.1m: 배관 벽 2.0~재질한계(기본 2.5, 메쌓기면 2.0로 절삭),
  일반 벽 0.5~재질한계(기본 1.5). 높이 기준 = 근입 0.5 위 기준선~상단
- 재질 선택(메쌓기 2.0/찰쌓기 3.0/콘크리트 5.0m) — 한계 초과 시
  토스트로 재질 변경 안내, 자동 높이는 기본값 고정(사용자 확정)
- 다단 단 수: 숫자 입력 + +/- 버튼 병행, 입력기 스피너 제거
- 저장 스키마: revetx 값이 {x,d,h,m} 객체로 확장(구 숫자 형식 호환)
- 인터페이스 3종을 Wire로 이전(Cross_View 700줄 제한)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:21:30 +09:00

287 lines
12 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 { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import { L } from "./B06_Section_UI_Section_Common";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
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;
/** 이동 조작 가능 여부 — 집수정(자리 고정)은 숨긴다. */
canNudge: (key: RevetKey) => boolean;
/** 상황에 안 맞는 선택지 숨김(2026-08-22 사용자) — 기하가 판정한 가용성. */
optionsFor: () => { revetAllowed: boolean; basinLUAllowed: boolean };
/** 다단 기슭막이 상태 — 유출 벽 선택 시 단 수 행 표시 판단에 쓴다. */
extraState: () => { canAdd: boolean; count: number };
/** 다단 기슭막이 단 수 지정 — 지형 허용보다 크면 기하가 자르고 토스트로 알린다. */
setExtraCount: (count: number) => void;
}
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;
function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b06-structure-panel__btn";
button.textContent = label;
button.title = title;
button.addEventListener("click", (event) => {
// 카드 선택·팬으로 번지면 도면이 다시 그려져 방금 맞춘 배율이 날아간다.
event.stopPropagation();
onClick();
});
return button;
}
/** 구조물 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */
export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHandle {
const root = document.createElement("div");
root.className = "b06-structure-panel";
root.classList.add("is-hidden");
// 창 안에서의 클릭이 카드 선택으로 번지지 않게 한 번에 막는다.
root.addEventListener("click", (event) => event.stopPropagation());
const title = document.createElement("span");
title.className = "b06-structure-panel__title";
const value = document.createElement("span");
value.className = "b06-structure-panel__value";
let current: RevetKey | null = null;
const act = (run: (key: RevetKey) => void) => () => {
if (current) run(current);
};
const buttons = document.createElement("div");
buttons.className = "b06-structure-panel__buttons";
buttons.append(
makeButton(
"◀",
L("B06_Cross_Revet_Left"),
act((key) => deps.nudge(key, STEP_M)),
),
makeButton(
"▶",
L("B06_Cross_Revet_Right"),
act((key) => deps.nudge(key, -STEP_M)),
),
makeButton(
"▲",
L("B06_Cross_Revet_Up"),
act((key) => deps.nudgeSlope(key, -STEP_M)),
),
makeButton(
"▼",
L("B06_Cross_Revet_Down"),
act((key) => deps.nudgeSlope(key, STEP_M)),
),
makeButton(
"↺",
L("B06_Cross_Revet_Reset"),
act((key) => deps.reset(key)),
),
);
// 높이·재질 행(2026-08-22 사용자) — 높이 ±0.1m, 재질이 한계를 정한다.
const heightRow = document.createElement("div");
heightRow.className = "b06-structure-panel__struct";
const heightLabel = document.createElement("span");
heightLabel.textContent = L("B06_Cross_Height_Label");
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)),
);
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]}(~${materialLimit(material).toFixed(1)}m)`;
materialSelect.append(option);
}
materialSelect.addEventListener("change", () => {
if (current) deps.setMaterial(current, materialSelect.value as RevetMaterial);
});
heightRow.append(heightLabel, heightMinus, heightValue, heightPlus, materialSelect);
// 유입측 구조물 형식 드롭다운(2026-08-22 사용자) — 자동/기슭막이/집수정 I·ㄴ·ㄷ.
const structureRow = document.createElement("label");
structureRow.className = "b06-structure-panel__struct";
const structureLabel = document.createElement("span");
structureLabel.textContent = L("B06_Cross_Struct_Label");
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);
});
structureRow.append(structureLabel, select);
// 다단 기슭막이 단 수(2026-08-22 사용자) — 숫자 입력 + +/- 병행(스피너는 CSS 제거).
const extraRow = document.createElement("label");
extraRow.className = "b06-structure-panel__struct";
const extraLabel = document.createElement("span");
extraLabel.textContent = L("B06_Cross_Extra_Count");
const countInput = document.createElement("input");
countInput.type = "number";
countInput.min = "0";
countInput.max = "9";
countInput.step = "1";
countInput.className = "b06-structure-panel__count";
const clampCount = (value: number): number =>
Math.max(0, Math.min(9, Math.round(Number.isFinite(value) ? value : 0)));
countInput.addEventListener("change", () => {
const requested = clampCount(Number(countInput.value));
countInput.value = String(requested);
deps.setExtraCount(requested);
});
const countMinus = makeButton("", L("B06_Cross_Extra_Remove"), () =>
deps.setExtraCount(clampCount(Number(countInput.value) - 1)),
);
const countPlus = makeButton("", L("B06_Cross_Extra_Add"), () =>
deps.setExtraCount(clampCount(Number(countInput.value) + 1)),
);
extraRow.append(extraLabel, countMinus, countInput, countPlus);
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";
head.append(title, closeButton);
root.append(head, value, structureRow, heightRow, extraRow, buttons);
return {
root,
show: (key) => {
current = key;
root.classList.toggle("is-hidden", key === null);
if (!key) return;
const isExtra = key.startsWith("extra");
const movable = deps.canNudge(key);
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") {
const structure = deps.structureFor();
// 상황에 안 맞는 선택지는 숨긴다(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;
}
buttons.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);
}
// 단 수 행은 **유출 벽에서만** — 성토부 5m 이상(의무)이거나 이미 단이 있을 때.
const extra = deps.extraState();
extraRow.classList.toggle(
"is-hidden",
!(key === "outlet" && (extra.canAdd || extra.count > 0)),
);
countInput.value = String(extra.count);
const adjust = deps.adjustFor(key);
// 조작 요약 — 0이면 "자동". 좌우는 안/바깥, 상하는 위/아래로 적는다.
const parts: string[] = [];
if (Math.abs(adjust.x) > 1e-9) {
parts.push(
`${L(adjust.x > 0 ? "B06_Cross_Revet_Outward" : "B06_Cross_Revet_Inward")} ${Math.abs(adjust.x).toFixed(1)}m`,
);
}
if (Math.abs(adjust.d) > 1e-9) {
parts.push(
`${L(adjust.d > 0 ? "B06_Cross_Revet_DirDown" : "B06_Cross_Revet_DirUp")} ${Math.abs(adjust.d).toFixed(1)}m`,
);
}
const moved = parts.length ? parts.join(" · ") : L("B06_Cross_Revet_Auto");
const pipeLength = deps.pipeLengthM();
value.textContent =
pipeLength === null ? moved : `${L("B06_Cross_Revet_Pipe")} ${pipeLength}m · ${moved}`;
},
};
}