- 집수정 조정값(BasinAdjust: 내공 폭·높이, 좌우 lateralM, 상하 slopeM) 신설. 측점별 세션 보관 + 백엔드 design.basin_adjust 저장 경로(Schema·Router) 연동 - 조정창에 집수정 조정 항목 추가, I형 관 시작점을 벽 안쪽 변과 원지반의 교차점으로 산출(이분법), 집수정 성토(되메움)선 5° → 성토 비탈 1:1.2로 통일 - 설계선 트림 수정: 배수관 레이아웃이 사면 폴리라인을 준 쪽은 그 시작점(노견)에서 설계선을 끊는다 — 종전에는 트림 경계에서 끝점 표고를 벽 상단으로 강제 스냅해 집수정을 좌우로 옮기면 마지막 구간이 수직으로 튀는 선이 남았다(사용자 지적) - basinApproachPoints의 길이 0 중복 꼭짓점 제거 검증: 4+4.3(집수정 I형 좌우 2.9m 이동)·13+15.7·5+0.0·2+0.0에서 급경사 세그먼트 0건 · tsc --noEmit 통과 · ruff check 통과 · prettier 적용 · pytest tmp/tests/ 148 passed, 7 skipped · B06_Section 700줄 초과 0건. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
380 lines
16 KiB
TypeScript
380 lines
16 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 { 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 { 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;
|
||
basinAdjustFor: () => BasinAdjust;
|
||
updateBasin: (patch: Partial<BasinAdjust>) => void;
|
||
resetBasin: () => 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;
|
||
}
|
||
|
||
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;
|
||
|
||
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;
|
||
}
|
||
|
||
/** 항목 행 — 1행 이름 라벨 + 2행 값 조작(2026-08-22 사용자 확정 2행 구조). */
|
||
function makeRow(labelText: string): { row: HTMLElement; controls: HTMLElement } {
|
||
const row = document.createElement("div");
|
||
row.className = "b06-structure-panel__struct";
|
||
const label = document.createElement("span");
|
||
label.className = "b06-structure-panel__label";
|
||
label.textContent = labelText;
|
||
const controls = document.createElement("div");
|
||
controls.className = "b06-structure-panel__controls";
|
||
row.append(label, controls);
|
||
return { row, controls };
|
||
}
|
||
|
||
/** 구조물 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */
|
||
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";
|
||
|
||
// 정보 표시(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);
|
||
|
||
const basinRow = makeRow("집수정 크기·위치");
|
||
basinRow.controls.classList.add("b06-structure-panel__basin-buttons");
|
||
const basinButton = (label: string, titleText: string, patch: () => Partial<BasinAdjust>) =>
|
||
makeButton(label, titleText, () => deps.updateBasin(patch()));
|
||
const basinHeightUp = basinButton("높이+", "내부 높이 +0.1m", () => ({
|
||
innerHeightM: Math.min(2, deps.basinAdjustFor().innerHeightM + BASIN_STEP_M),
|
||
}));
|
||
const basinHeightDown = basinButton("높이-", "내부 높이 -0.1m", () => ({
|
||
innerHeightM: Math.max(1, deps.basinAdjustFor().innerHeightM - BASIN_STEP_M),
|
||
}));
|
||
const basinWidthDown = basinButton("폭-", "내부 폭 -0.1m", () => ({
|
||
innerWidthM: Math.max(1, deps.basinAdjustFor().innerWidthM - BASIN_STEP_M),
|
||
}));
|
||
const basinWidthUp = basinButton("폭+", "내부 폭 +0.1m", () => ({
|
||
innerWidthM: Math.min(2, deps.basinAdjustFor().innerWidthM + BASIN_STEP_M),
|
||
}));
|
||
const basinSlopeUp = basinButton("사면▲", "1:1.2 성토선을 따라 위로", () => ({
|
||
slopeM: Math.max(0, deps.basinAdjustFor().slopeM - BASIN_STEP_M),
|
||
}));
|
||
const basinSlopeDown = basinButton("사면▼", "1:1.2 성토선을 따라 아래로", () => ({
|
||
slopeM: deps.basinAdjustFor().slopeM + BASIN_STEP_M,
|
||
}));
|
||
const basinIn = basinButton("노견◀", "노견측으로 0.1m", () => ({
|
||
lateralM: Math.max(0, deps.basinAdjustFor().lateralM - BASIN_STEP_M),
|
||
}));
|
||
const basinOut = basinButton("바깥▶", "노견 반대측으로 0.1m", () => ({
|
||
lateralM: deps.basinAdjustFor().lateralM + BASIN_STEP_M,
|
||
}));
|
||
basinRow.controls.append(
|
||
basinHeightUp,
|
||
basinHeightDown,
|
||
basinWidthDown,
|
||
basinWidthUp,
|
||
basinSlopeUp,
|
||
basinSlopeDown,
|
||
basinIn,
|
||
basinOut,
|
||
);
|
||
|
||
// 다단 기슭막이 단 수(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);
|
||
|
||
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,
|
||
basinRow.row,
|
||
materialRow,
|
||
heightRow,
|
||
extraRow,
|
||
moveRow.row,
|
||
);
|
||
|
||
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);
|
||
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 isI = structure === "I";
|
||
for (const button of [basinHeightUp, basinHeightDown, basinWidthDown, basinWidthUp]) {
|
||
button.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}단`;
|
||
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"));
|
||
slopeLine.textContent =
|
||
`${L("B06_Cross_Slope_Label")} ` +
|
||
(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`
|
||
: L("B06_Cross_Revet_Auto"));
|
||
},
|
||
};
|
||
}
|