Files
Aislo/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts
T
eomsangdonandClaude Opus 5 917cdce5df feat(B06): 집수정 9키 조작 · 좌우 화면 기준 통일 · 계류측 성토부 체계 도입
① 집수정 조작을 9키(3×3)로 — 십자 축은 이동(◀▶ 좌우 / ▲▼ 사면 대각),
   네 모서리는 크기(⇕ 내부 높이 · ⇔ 내부 폭), 가운데는 초기화. 기슭막이
   십자와 같은 축 규칙·버튼 크기라 행열이 맞는다. I형은 내공 개념이 없어
   네 모서리만 숨긴다.
② 좌우 반전 해소 — 집수정 lateralM이 계류측(outward) 기준값에 그대로 묶여
   유입이 우측인 측점에서 ◀가 화면 오른쪽으로 갔다. 버튼을 **화면 기준**으로
   받아 카드가 부호를 환산한다. 노견 안쪽(음수)은 0에서 멈추고 토스트로 알린다.
③ 집수정 계류측에 **유출측과 같은 성토부 체계** 적용(ㄴ·ㄷ형) — 구조물 계류측
   끝이 원지반 위로 나오면 1:1.2 성토부선을 긋고, 5m를 넘으면 다단 기슭막이를
   둘 수 있다(키 , 단 수 행 별도). 원지반 안이면 종전 절토선,
   I형은 관 하단 3도 되메움선이 맡는다. 좌우 이동 시 마감선을 억제하던 임시
   조건은 제거했다(급경사 선의 근본 원인은 이미 설계선 트림에서 해결).
- 700줄 유지: attachBasin(Basin)·buildExtrasAt(Extra) 추출 + 주석 정리

검증: 4+4.3 ㄴ형 좌우 2m — 성토부선 2.41m가 원지반에 0.001m 오차로 접함
(종전 마감선 없음) · ◀ 클릭 시 4+4.3(유입 좌)·13+15.7(유입 우) 모두 화면
왼쪽으로 이동 · 9키 3×3 좌표 정렬 확인 · tsc·ruff·prettier 통과 ·
pytest 148 passed, 7 skipped · B06_Section 700줄 초과 0건.

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

442 lines
18 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 { 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;
/** 집수정 좌우 — **화면 기준**(+ = 화면 왼쪽). 부호 환산·한계 토스트는 카드가 한다. */
nudgeBasinLateral: (screenDeltaM: number) => void;
resetBasin: () => void;
/** 집수정 계류측 다단 상태·단 수 지정(2026-08-22 — 유출측과 같은 체계). */
basinExtraState: () => { canAdd: boolean; count: number };
setBasinExtraCount: (count: number) => 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);
// 집수정 조작 = **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),
})),
),
);
// 집수정 계류측 다단 기슭막이 단 수 — 유출측 행과 같은 형식([- 값 +]).
const basinExtraParts = makeRow("집수정 계류측 기슭막이(단)");
const basinExtraRow = basinExtraParts.row;
const basinCountValue = document.createElement("span");
basinCountValue.className = "b06-structure-panel__hval";
let basinCount = 0;
const clampBasinCount = (value: number): number => Math.max(0, Math.min(9, Math.round(value)));
basinExtraParts.controls.append(
makeButton("", L("B06_Cross_Extra_Remove"), () =>
deps.setBasinExtraCount(clampBasinCount(basinCount - 1)),
),
basinCountValue,
makeButton("", L("B06_Cross_Extra_Add"), () =>
deps.setBasinExtraCount(clampBasinCount(basinCount + 1)),
),
);
// 다단 기슭막이 단 수(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,
basinExtraRow,
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 basinExtra = deps.basinExtraState();
basinExtraRow.classList.toggle(
"is-hidden",
!(isBasin && (basinExtra.canAdd || basinExtra.count > 0)),
);
basinCount = basinExtra.count;
basinCountValue.textContent = `${basinExtra.count}단`;
// 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);
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"));
},
};
}