Files
Aislo/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts
T
eomsangdonandClaude Opus 5 03840ff3d6 feat(B06): 세월교 좌측 「구조물 배치」 폼을 조정창 로직에 연결한다
좌측 폼의 세월교 상세 항목(관종·관경·수량·월류 폭·날개벽 유입/유출)이 도면에
전혀 반영되지 않았다. `applyPipeOptionsToCache`가 `section.culvert`만 보고
세월교 측점(`section.ford`)에서는 즉시 return했기 때문이다.

프론트는 구조물 배치의 상세 UI를 쓰고 로직은 조정창 제어기 것을 쓴다
(2026-08-30 사용자 확정).

- `applyFordFormOptions()` 신설 — 폼 옵션 키를 `FordControl.setPipe`/`setWing`
  인자로 옮긴다. 캐시 반영·바닥판 연장 재계산·정본 예약·카드 갱신은 제어기에
  이미 있어 새로 만들지 않았다.
- `FordControl.setPipe` 확장 — `pipe_kind`(관종)·`ford_width_m`(월류 폭 =
  `span_m`) 추가. 두 값은 조정창에 칸이 없고 폼만 보낸다.
- `withFordSpec()` — 폼 기본값을 `section.ford` 스펙에서 읽는다. 두 창이 같은
  스펙을 보므로 어느 쪽에서 만졌든 값이 갈리지 않는다(수량(련) 빈 칸 해소).

월류 높이(`ford_height_m`)는 백엔드 `_ford_set`이 읽지 않는다 — 세월교는 구체
위 노면이라 파임이 없고, 물넘이포장 전용 값이다. 폼에는 남되 도면 반영처는 없다.

자체검증(공용 브라우저 5174, 측점 7+9.7 세월교):
- tsc --noEmit 통과.
- 폼 날개 길이(유입) 2→4 → 조정창 「날개 길이 4.0m」, 바닥판 7.63m→9.04m
  (= +2×cos45°). 수정 전에는 둘 다 그대로였다.
- 폼 수량 3 → 조정창 「3련」, 폼 관경 Ø1200 → 조정창 select 1200.
- 재선택 왕복: 월류폭 12·수량 3·관경 1200·날개길이 4 그대로 되읽힘(스펙 정본).
- 진입 시 수량(련) 칸 = 1(수정 전 빈 칸), 관종 파형강관.
- 조작값은 원래 값(10/1/1000/2)으로 되돌려 놓았다.

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

301 lines
11 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_Ford_Panel.ts
* 세월교 측벽 **조정 오버레이 창**(2026-08-25 사용자 확정).
*
* 배수관 조정창(`_Cross_Structure_Panel.ts`)과 같은 템플릿(공용 부품
* `_Cross_Panel_Base.ts`)을 쓴다 — 머리줄(제목·닫기), 2행 항목 구조,
* **십자 조작 세트는 창 맨 아래**, 키보드 방향키 제어 포함(2026-08-25 일원화).
*
* 조작: 측벽 높이 · 측벽 좌우(노견 확장) · 측벽 상하(1:1.2 대각) · 관경 · 관 수량 ·
* **날개벽(설치·짧은쪽 높이·길이·각도)**(2026-08-25 사용자 — 항목 없던 것 추가).
* 바닥판 연장은 날개벽 길이×cos(각도)가 정하므로 읽기 전용으로 보여 준다.
* ========================================================================== */
import type { FordWingSpec } from "./B06_Section_Api_Fetch";
import type { FordAdjust, FordWallAdjust, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom";
import {
bindArrowKeys,
buildPanelShell,
dpadButton,
makeButton,
makeRow,
} from "./B06_Section_UI_Cross_Panel_Base";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
export interface FordPanelDeps {
/** 지금 조작값(적용된 결과 — 한계에 잘린 뒤 값). */
adjustFor: (role: FordWallRole) => FordWallAdjust;
/** 실제 그려진 벽 높이(m) — 자동일 때도 숫자를 보여 준다. */
heightFor: (role: FordWallRole) => number;
/** 높이 ±0.1m. */
nudgeHeight: (role: FordWallRole, deltaM: number) => void;
/** 좌우: 화면 좌(+)/우(−)로 미는 양(m). 부호 환산은 카드가 한다. */
nudge: (role: FordWallRole, screenDeltaM: number) => void;
/** 상하: 1:1.2 사면을 타는 대각 이동(수평 성분 m, + = 사면 아래). */
nudgeSlope: (role: FordWallRole, deltaM: number) => void;
/** 자동 자리로 되돌린다(세 축 모두). */
reset: (role: FordWallRole) => void;
/** 관경(mm)·수량(련) — 값은 B05 정본(`pipe_points`)에 되돌려 쓴다. */
pipeDiameterMm: () => number;
setPipeDiameterMm: (value: number) => void;
pipeCount: () => number;
setPipeCount: (value: number) => void;
/** 날개벽 제원(그 측) — 설치·짧은쪽 높이·길이·각도(2026-08-25 사용자). */
wingFor: (role: FordWallRole) => FordWingSpec | null;
setWing: (
role: FordWallRole,
patch: Partial<{ installed: boolean; height_m: number; length_m: number; angle_deg: number }>,
) => void;
/** 바닥판 길이(m) — 날개벽 투영이 정한 값을 읽기 전용으로 보여 준다. */
slabLengthM: () => number;
/** 창을 닫는다 = 구조물 선택 해제. */
close: () => void;
}
export interface FordPanelHandle {
root: HTMLElement;
/** null이면 숨긴다. 값이 오면 그 측벽 기준으로 다시 그린다. */
show: (role: FordWallRole | null) => void;
}
/** 한 걸음 — 집수정 9키와 같은 0.1m 눈금. */
const STEP_M = 0.1;
/** 날개벽 각도 한 걸음(°). */
const ANGLE_STEP_DEG = 5;
/** 관경 선택지(mm) — B05 레지스트리 `ford_bridge.pipe_diameter_mm` choices와 같다. */
const DIAMETER_CHOICES_MM = [800, 1000, 1200, 1500];
/** 세월교 측벽 조정 오버레이 창을 만든다. 처음에는 숨어 있다.
* `opts.dock` — 좌측 [횡단 조정] 사본(2026-08-29): 흐름 배치 + 방향키 미장착. */
export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): FordPanelHandle {
const shell = buildPanelShell(() => deps.close());
const { root, title } = shell;
if (opts?.dock) root.classList.add("b06-structure-panel--dock");
const value = document.createElement("div");
value.className = "b06-structure-panel__value";
const heightLine = document.createElement("span");
const moveLine = document.createElement("span");
const slabLine = document.createElement("span");
value.append(heightLine, moveLine, slabLine);
let current: FordWallRole | null = null;
const act = (run: (role: FordWallRole) => void) => () => {
if (current) run(current);
};
const heightRow = makeRow("측벽 높이");
heightRow.controls.append(
makeButton(
"",
"측벽 높이 0.1m 낮추기",
act((role) => deps.nudgeHeight(role, -STEP_M)),
),
makeButton(
"",
"측벽 높이 0.1m 높이기",
act((role) => deps.nudgeHeight(role, STEP_M)),
),
);
const pipeRow = makeRow("관경 · 수량");
const diameter = document.createElement("select");
diameter.className = "b06-structure-panel__select";
for (const mm of DIAMETER_CHOICES_MM) {
const option = document.createElement("option");
option.value = String(mm);
option.textContent = ${mm}`;
diameter.append(option);
}
diameter.addEventListener("change", () => deps.setPipeDiameterMm(Number(diameter.value)));
const countValue = document.createElement("span");
countValue.className = "b06-structure-panel__count";
pipeRow.controls.append(
diameter,
makeButton("", "관 수량 1련 줄이기", () =>
deps.setPipeCount(Math.max(1, deps.pipeCount() - 1)),
),
countValue,
makeButton("", "관 수량 1련 늘리기", () => deps.setPipeCount(deps.pipeCount() + 1)),
);
// ── 날개벽(2026-08-25 사용자 — 제어 항목 추가): 설치 토글 + 높이·길이·각도.
// 각도는 관축 기준 벌어짐각 — 바닥판 연장 = 길이 × cos(각도)로 따라 바뀐다.
const wingRow = makeRow("날개벽");
const wingToggle = document.createElement("button");
wingToggle.type = "button";
wingToggle.className = "b06-structure-panel__btn b06-structure-panel__toggle";
wingToggle.textContent = "설치";
wingToggle.title = "날개벽 설치/해제 — 해제하면 바닥판 연장도 0이 된다.";
wingToggle.addEventListener("click", (event) => {
event.stopPropagation();
if (!current) return;
const wing = deps.wingFor(current);
deps.setWing(current, { installed: !(wing?.installed ?? false) });
});
wingRow.controls.append(wingToggle);
const wingValueRows: Array<{
row: HTMLElement;
value: HTMLSpanElement;
read: (wing: FordWingSpec) => string;
}> = [];
const makeWingValueRow = (
label: string,
stepText: string,
read: (wing: FordWingSpec) => string,
apply: (wing: FordWingSpec, direction: 1 | -1) => void,
): void => {
const parts = makeRow(label);
const readout = document.createElement("span");
readout.className = "b06-structure-panel__hval";
const nudgeWing = (direction: 1 | -1) => () => {
if (!current) return;
const wing = deps.wingFor(current);
if (wing) apply(wing, direction);
};
parts.controls.append(
makeButton("", `${label} ${stepText} 줄이기`, nudgeWing(-1)),
readout,
makeButton("", `${label} ${stepText} 늘리기`, nudgeWing(1)),
);
wingValueRows.push({ row: parts.row, value: readout, read });
};
makeWingValueRow(
"날개 짧은쪽 높이",
`${STEP_M}m`,
(wing) => `${(wing.height_m ?? 0).toFixed(1)}m`,
(wing, direction) => {
if (!current) return;
deps.setWing(current, {
height_m: Math.max(0.3, (wing.height_m ?? 1) + direction * STEP_M),
});
},
);
makeWingValueRow(
"날개 길이",
`${STEP_M}m`,
(wing) => `${(wing.length_m ?? 0).toFixed(1)}m`,
(wing, direction) => {
if (!current) return;
deps.setWing(current, {
length_m: Math.max(0, (wing.length_m ?? 0) + direction * STEP_M),
});
},
);
makeWingValueRow(
"날개 각도",
`${ANGLE_STEP_DEG}°`,
(wing) => `${Math.round(wing.angle_deg ?? 45)}°`,
(wing, direction) => {
if (!current) return;
deps.setWing(current, {
angle_deg: Math.min(85, Math.max(5, (wing.angle_deg ?? 45) + direction * ANGLE_STEP_DEG)),
});
},
);
// 십자 조작 세트 — **창 맨 아래**(템플릿 규약).
const moveRow = makeRow("이동");
moveRow.controls.classList.add("b06-structure-panel__buttons");
moveRow.controls.append(
dpadButton(
"▲",
"up",
"사면 위로(1:1.2 대각)",
act((role) => deps.nudgeSlope(role, -STEP_M)),
),
dpadButton(
"◀",
"left",
"화면 왼쪽으로",
act((role) => deps.nudge(role, STEP_M)),
),
dpadButton("↺", "reset", "이 측벽 조정값 초기화", act(deps.reset)),
dpadButton(
"▶",
"right",
"화면 오른쪽으로",
act((role) => deps.nudge(role, -STEP_M)),
),
dpadButton(
"▼",
"down",
"사면 아래로(1:1.2 대각)",
act((role) => deps.nudgeSlope(role, STEP_M)),
),
);
root.append(
value,
heightRow.row,
pipeRow.row,
wingRow.row,
...wingValueRows.map((entry) => entry.row),
moveRow.row,
);
const arrows = opts?.dock
? { attach: (): void => undefined, detach: (): void => undefined }
: bindArrowKeys(root, () => [moveRow.controls]);
function render(): void {
if (!current) return;
const role = current === "inlet" ? "유입" : "유출";
title.textContent = `세월교 ${role} 측벽`;
const adjust: FordWallAdjust = deps.adjustFor(current);
heightLine.textContent =
`높이 ${deps.heightFor(current).toFixed(2)}m` + (adjust.heightM === null ? " (자동)" : "");
moveLine.textContent = `좌우 ${adjust.lateralM.toFixed(1)}m · 상하 ${adjust.slopeM.toFixed(1)}m`;
slabLine.textContent = `바닥판 ${deps.slabLengthM().toFixed(2)}m`;
diameter.value = String(deps.pipeDiameterMm());
countValue.textContent = `${deps.pipeCount()}련`;
const wing = deps.wingFor(current);
const installed = wing?.installed ?? false;
wingToggle.classList.toggle("is-on", installed);
wingToggle.setAttribute("aria-pressed", String(installed));
for (const entry of wingValueRows) {
entry.row.classList.toggle("is-hidden", !installed);
if (wing && installed) entry.value.textContent = entry.read(wing);
}
}
return {
root,
show(role) {
current = role;
root.classList.toggle("is-hidden", role === null);
arrows.detach();
if (role !== null) arrows.attach();
render();
},
};
}
/** 측점별 세월교 조작값 제어 — 페이지(세션·정본)가 구현한다. */
export interface FordControl {
adjustFor: (chainageM: number) => FordAdjust;
update: (chainageM: number, role: FordWallRole, patch: Partial<FordWallAdjust>) => void;
reset: (chainageM: number, role: FordWallRole) => void;
/** 관경(mm)·수량(련)·관종·월류 폭 저장 — B05 정본(`pipe_points`)으로 간다.
* 관종·월류 폭은 조정창에 칸이 없고 좌측 「구조물 배치」 폼만 보낸다(2026-08-30 사용자). */
setPipe: (
chainageM: number,
patch: {
pipe_diameter_mm?: number;
pipe_count?: number;
pipe_kind?: string;
ford_width_m?: number;
},
) => void;
/** 날개벽 제원 저장(2026-08-25 사용자 — 조정창에서 직접 제어). 같은 정본으로 간다. */
setWing: (
chainageM: number,
role: FordWallRole,
patch: Partial<{ installed: boolean; height_m: number; length_m: number; angle_deg: number }>,
) => void;
/** 지금 선택된 측벽(측점별). */
selectedFor: (chainageM: number) => FordWallRole | null;
select: (chainageM: number, role: FordWallRole | null) => void;
}