Files
Aislo/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts
T
eomsangdonandClaude Opus 5 97bf00a8ef fix(B06): 유출 관 끝점·전면 근입 기준 + 줌 유지·조정창 레이아웃 정리
- 유출 관 하단 끝점 = 벽 바닥 +0.5m 기준선과 전면 경사선(1:0.3)의 교차점
  (성토부선 시작점과 동일 자리) — 정수 맞춤 재배치에도 동일 적용
- 근입 0.5m는 경사선(전면) 측 깊이 기준 — 전면 발끝 지반 아래 0.5까지
  수렴 반복으로 내림(배관 벽·추가 벽 공통, 뜬 벽은 기준선 아래 0.5)
- 카드 내부 조작(조정창 등)으로 다시 그려져도 줌·팬 배율 유지
  (측점별 상태 보관, ZoomPanState)
- 조정창: 재질 행을 높이 행 위 별도 행으로(폭 축소 — 한계는 툴팁),
  추가 기슭막이(단) 행은 유출 벽에서 항상 표시(레이아웃 널뜀 방지)

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

294 lines
13 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 사용자 ④ — 높이 행 **위**, 폭 축소: 한계는 툴팁으로).
const materialRow = document.createElement("label");
materialRow.className = "b06-structure-panel__struct";
const materialLabelEl = document.createElement("span");
materialLabelEl.textContent = L("B06_Cross_Mat_Label");
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);
});
materialRow.append(materialLabelEl, materialSelect);
// 높이 행 — ±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)),
);
heightRow.append(heightLabel, heightMinus, heightValue, heightPlus);
// 유입측 구조물 형식 드롭다운(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, materialRow, 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);
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");
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}`;
},
};
}