feat(B06): 관 · 옹벽 칸 한 곳 — 조정 창은 손잡이 · 읽기만(PLAN 24-4a)

- 옹벽 · 세월교 · BOX 조정 창의 값 칸(셀렉트 · −/+) 걷음 — 읽기 · 십자 · 그림 끌기 손잡이만(input 0)
- 그림 끌기 손잡이: 고른 구조물을 끌면 십자와 같은 걸음 · 같은 동작 · 끄는 동안 미리 보기
- 좌측 [횡단 조정] 사본 상자 없앰 — 폼에 없던 줄(읽기 · 단 수 · 옵션 · 집수정 내공 · 세월교 측벽 높이 · 다단 벽)만 폼 칸에 옮겨 붙음
- 집수정 기준측점 전 · 후를 폼 칸으로(옵션 보이기 따름) · 옮겨 붙은 줄도 옵션 보이기 규칙
- 폼 값 = 칸 전부: 비운 칸은 그림 자동 · 가게(pipes)에서도 지움 · 조정 예약(culvertopt)에 따로 담지 않음
- 시험 test_b06_adjust_single_form 6건 · 거울 시험(집수정 전 · 후 폼 칸) 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
This commit is contained in:
2026-09-29 23:54:12 +09:00
co-authored by Claude Opus 5.5
parent fe30cdbbd8
commit 7f04bf961c
19 changed files with 756 additions and 306 deletions
-1
View File
@@ -174,7 +174,6 @@ export async function mountCrossView(
focusChainage: (chainageM) => {
if (!isWallSelecting()) focusStationAt(chainageM);
},
queuePipeOptions: (chainageM, patch) => stationControls.queueCulvertOptions(chainageM, patch),
applyPipeOptions: (chainageM, patch) =>
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
});
@@ -38,6 +38,7 @@ import type {
import {
BOX_SIZE_CUSTOM,
BOX_SIZE_PRESETS,
createBasinSplitFields,
createRevetmentFields,
createRevetSideGroup,
createWingFields,
@@ -283,11 +284,15 @@ export function createFacilityOptionsForm(
const basinMaterialField = labeled("형태", basinMaterial, "inlet_basin_material");
// 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자).
const basinLength = numberInput("0.1", "0", "제안 2");
// 기준측점 전 · 후 — 옛 횡단 조정창에만 있던 칸을 폼으로(계획서 24-4a).
const basinSplit = createBasinSplitFields(basinLength);
basinSplit.onChange(() => emit());
const basinRows = [
grid(
labeled("형식", basinForm, "inlet_basin_form"),
labeled("길이 (m)", stepper(basinLength, 1), "inlet_basin_length_m"),
),
basinSplit.row,
];
const height = (): string =>
String(pipeWallMinHeightM(Number(pipeDiameter.value || "1000") / 1000));
@@ -605,6 +610,7 @@ export function createFacilityOptionsForm(
basinForm.value = text("inlet_basin_form");
basinMaterial.value = text("inlet_basin_material");
basinLength.value = text("inlet_basin_length_m");
basinSplit.write(options);
inletRevet.write(options);
outletRevet.write(options);
// 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다.
@@ -658,6 +664,7 @@ export function createFacilityOptionsForm(
if (basinForm.value) options.inlet_basin_form = basinForm.value;
if (basinMaterial.value) options.inlet_basin_material = basinMaterial.value;
putNumber(options, "inlet_basin_length_m", basinLength.value);
basinSplit.read(options);
} else {
inletRevet.read(options);
}
@@ -439,6 +439,61 @@ export function createRevetmentFields(
};
}
/** 집수정 기준측점 전 · 후 한 줄 — 옛 조정창에만 있던 칸(계획서 24-4a). 길이 칸과 짝:
* 전 + 후 = 길이 · 나중에 고친 쪽이 살고 반대쪽이 나머지(기슭막이 칸과 같은 규칙). */
export function createBasinSplitFields(length: HTMLInputElement): {
row: HTMLDivElement;
write: (options: Record<string, string | number>) => void;
read: (target: Record<string, string | number>) => void;
onChange: (handler: () => void) => void;
} {
const before = numberInput("0.1", "0", "제안 1");
const after = numberInput("0.1", "0", "제안 1");
let last: HTMLInputElement = before;
const sync = (): void => {
const total = Number.parseFloat(length.value);
if (!Number.isFinite(total)) return;
const other = last === before ? after : before;
const kept = Math.min(Math.max(Number.parseFloat(last.value) || 0, 0), total);
last.value = kept.toFixed(1);
other.value = (total - kept).toFixed(1);
};
return {
row: grid(
labeled("기준측점 전 (m)", stepper(before, 0.5), "inlet_basin_before_m"),
labeled("기준측점 후 (m)", stepper(after, 0.5), "inlet_basin_after_m"),
),
write(options) {
before.value =
options.inlet_basin_before_m !== undefined ? String(options.inlet_basin_before_m) : "";
after.value =
options.inlet_basin_after_m !== undefined ? String(options.inlet_basin_after_m) : "";
},
read(target) {
// 전/후는 0도 값(기준이 곧 끝) — 빈칸만 뺀다.
for (const [key, input] of [
["inlet_basin_before_m", before],
["inlet_basin_after_m", after],
] as const) {
const value = Number.parseFloat(input.value);
if (Number.isFinite(value) && value >= 0) target[key] = Number(value.toFixed(1));
}
},
onChange(handler) {
for (const input of [before, after]) {
input.addEventListener("change", () => {
last = input;
sync();
handler();
});
}
length.addEventListener("change", () => {
if (before.value || after.value) sync();
});
},
};
}
/* ── BOX암거 ────────────────────────────────────────────────────────────── */
/** 본체 규격 프리셋 — 실무 관측 규격이다(2026-08-17 사용자 확정). 그 밖의 규격은
@@ -41,6 +41,10 @@ export function createHiddenOptions(root: HTMLElement): HiddenOptions {
row.classList.toggle("is-option-hidden", all);
});
};
// 횡단 보기가 칸에 옮겨 붙이는 줄(단 수 등 · 계획서 24-4a)도 같은 규칙 — 줄이 붙을 때마다 다시 건다.
// (브라우저 밖 거울 시험 DOM 에는 관찰자가 없다.)
if (typeof MutationObserver !== "undefined")
new MutationObserver(apply).observe(root, { childList: true, subtree: true });
return {
remember(options) {
stored = { ...options };
@@ -35,6 +35,8 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
"inlet_basin_form",
"inlet_basin_material",
"inlet_basin_length_m",
"inlet_basin_before_m",
"inlet_basin_after_m",
...revetKeys("inlet"),
"outlet_type",
...revetKeys("outlet"),
+12 -1
View File
@@ -8,6 +8,10 @@
* 세운다(adopt). 값·동작이 같은 함수라 오버레이·좌측 어느 쪽을 만져도 카드
* 재렌더에서 두 창이 함께 새 값으로 그려진다 — 별도 동기화 코드가 없다.
* 카드가 여럿이어도 슬롯은 하나 — 마지막으로 고른 구조물의 사본만 선다.
*
* 값 칸은 좌측 폼 하나(계획서 24-4a) — 사본의 줄은 **모두** 폼 칸(유입구 · 유출구 · 독립 좌우 ·
* 날개벽 · 추가 기슭막이)으로 옮겨 붙고, 폼과 같은 값의 줄은 사본이 아예 만들지 않는다.
* 그래서 [횡단 조정] 상자 자체는 화면에 서지 않는다(사본을 담아 두는 자리로만 남음).
* ========================================================================== */
let dockRoot: HTMLDetailsElement | null = null;
@@ -37,15 +41,20 @@ export function setAdjustSideSlots(next: SideSlots): void {
/** 다단 벽에서 옮겨 오는 행 — 폼에 자기 칸이 없어 형태·높이·구간값이 온다.
* 단 수·옵션은 부모 벽의 값이라 여기 오지 않고 부모 칸에 남는다(2026-08-30 사용자). */
const EXTRA_ROWS = [
".b06-structure-panel__struct--readout",
".b06-structure-panel__struct--form",
".b06-structure-panel__struct--height",
".b06-structure-panel__struct--span",
].join(", ");
/** 유입구·유출구는 폼이 이미 가진 항목 말고 단 수·옵션만 받는다. */
/** 유입구·유출구·날개벽 칸은 폼에 없는 줄만 받는다 — 읽기(좌우 · 상하 · 구체 길이 · 표고) ·
* 단 수 · 옵션 · 집수정 내공 · 세월교 측벽 높이(24-4a). */
const SIDE_ROWS = [
".b06-structure-panel__struct--readout",
".b06-structure-panel__struct--tier",
".b06-structure-panel__struct--options",
".b06-structure-panel__struct--basin-size",
".b06-structure-panel__struct--wall-height",
].join(", ");
/** 이식 자리가 지금 화면에 서 있는지 — 숨겨진 그룹(다른 시설)이면 옮기지 않는다. */
@@ -158,6 +167,8 @@ function ensureDock(): void {
dockRoot = document.createElement("details");
dockRoot.className = "b06-adjust-dock";
dockRoot.open = true;
// 줄은 모두 폼 칸으로 옮겨 가므로 상자는 서지 않는다(24-4a).
dockRoot.hidden = true;
const summary = document.createElement("summary");
summary.textContent = "횡단 조정";
placeholder = document.createElement("p");
+27 -8
View File
@@ -11,8 +11,10 @@
import type { BoxSideAdjust, BoxSideRole } from "./B06_Section_UI_Cross_Box_Geom";
import {
bindArrowKeys,
bindDragHandle,
buildPanelShell,
dpadButton,
dragReadout,
makeRow,
} from "./B06_Section_UI_Cross_Panel_Base";
@@ -45,8 +47,8 @@ export interface BoxPanelHandle {
/** 한 걸음(m) — 사용자 지정 0.1m. */
const STEP_M = 0.1;
/** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다.
* `opts.dock` — 좌측 [횡단 조정] 사본(2026-08-29): 흐름 배치 + 방향키 미장착. */
/** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다. 값 칸은 없다(계획서 24-4a) —
* 오버레이 = 읽기 + 십자 + 그림 끌기 손잡이 · `opts.dock` = 좌측 폼 날개벽 칸으로 옮겨 붙는 읽기 줄. */
export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): BoxPanelHandle {
const shell = buildPanelShell(() => deps.close());
const { root, title } = shell;
@@ -54,10 +56,12 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo
const value = document.createElement("div");
value.className = "b06-structure-panel__value";
if (opts?.dock) value.classList.add("b06-structure-panel__struct--readout");
const lengthLine = document.createElement("span");
const riseLine = document.createElement("span");
const slopeLine = document.createElement("span");
value.append(lengthLine, riseLine, slopeLine);
const dragLine = dragReadout();
value.append(lengthLine, riseLine, slopeLine, dragLine);
let current: BoxSideRole | null = null;
const act = (run: (role: BoxSideRole) => void) => () => {
@@ -96,11 +100,22 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo
),
);
root.append(value, moveRow.row);
root.append(value, ...(opts?.dock ? [] : [moveRow.row]));
const arrows = opts?.dock
? { attach: (): void => undefined, detach: (): void => undefined }
: bindArrowKeys(root, () => [moveRow.controls]);
const off = { attach: (): void => undefined, detach: (): void => undefined };
const arrows = opts?.dock ? off : bindArrowKeys(root, () => [moveRow.controls]);
// 그림 위 끌기 손잡이 — ◀▶ 구체 길이 · ▲▼ 표고(십자와 같은 걸음 · 24-4a).
const drag = opts?.dock
? off
: bindDragHandle(root, {
stepM: () => (current ? STEP_M : null),
preview: (text) => (dragLine.textContent = text),
commit: (left, down) => {
if (!current) return;
if (left) deps.nudgeLength(current, left * STEP_M);
if (down) deps.nudgeRise(current, -down * STEP_M);
},
});
function render(): void {
if (!current) return;
@@ -125,7 +140,11 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo
root.dataset.side = role === null ? "" : (deps.wingRoleFor?.(role) ?? "");
root.dataset.panelTitle = role === null ? "" : "BOX암거";
arrows.detach();
if (role !== null) arrows.attach();
drag.detach();
if (role !== null) {
arrows.attach();
drag.attach();
}
render();
},
};
+37 -139
View File
@@ -6,17 +6,18 @@
* `_Cross_Panel_Base.ts`)을 쓴다 — 머리줄(제목·닫기), 2행 항목 구조,
* **십자 조작 세트는 창 맨 아래**, 키보드 방향키 제어 포함(2026-08-25 일원화).
*
* 조작: 측벽 높이 · 측벽 좌우(노견 확장) · 측벽 상하(1:1.2 대각) · 관경 · 관 수량 ·
* **날개벽(설치·짧은쪽 높이·길이·각도)**(2026-08-25 사용자 — 항목 없던 것 추가).
* 조작: 측벽 좌우(노견 확장) · 측벽 상하(1:1.2 대각) — 십자 · 그림 끌기. 측벽 높이는 폼 날개벽
* 칸으로 옮겨 붙는 줄 · 관경 · 수량 · 날개벽 제원은 폼 칸 하나(계획서 24-4a).
* 바닥판 연장은 날개벽 길이×cos(각도)가 정하므로 읽기 전용으로 보여 준다.
* ========================================================================== */
import type { FordWingSpec } from "./B06_Section_Api_Fetch";
import type { FordAdjust, FordWallAdjust, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom";
import {
bindArrowKeys,
bindDragHandle,
buildPanelShell,
dpadButton,
dragReadout,
makeButton,
makeRow,
} from "./B06_Section_UI_Cross_Panel_Base";
@@ -35,17 +36,6 @@ export interface FordPanelDeps {
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;
/** 창을 닫는다 = 구조물 선택 해제. */
@@ -60,13 +50,11 @@ export interface FordPanelHandle {
/** 한 걸음 — 집수정 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): 흐름 배치 + 방향키 미장착. */
* 관경 · 수량 · 날개벽은 좌측 폼 칸이 정본이라 창에 두지 않는다(계획서 24-4a).
* `opts.dock` — 좌측 폼에 옮겨 붙을 줄(읽기 · 측벽 높이)만 만든다 — 폼에 없던 칸이다.
* 오버레이는 읽기 + 십자 + 그림 끌기 손잡이. */
export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): FordPanelHandle {
const shell = buildPanelShell(() => deps.close());
const { root, title } = shell;
@@ -74,23 +62,30 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
const value = document.createElement("div");
value.className = "b06-structure-panel__value";
if (opts?.dock) value.classList.add("b06-structure-panel__struct--readout");
const heightLine = document.createElement("span");
const moveLine = document.createElement("span");
const slabLine = document.createElement("span");
value.append(heightLine, moveLine, slabLine);
const dragLine = dragReadout();
value.append(heightLine, moveLine, slabLine, dragLine);
let current: FordWallRole | null = null;
const act = (run: (role: FordWallRole) => void) => () => {
if (current) run(current);
};
// 측벽 높이 — 폼에 칸이 없던 값이라 폼 날개벽 칸으로 옮겨 붙는 줄(24-4a).
const heightRow = makeRow("측벽 높이");
heightRow.row.classList.add("b06-structure-panel__struct--wall-height");
const heightValue = document.createElement("span");
heightValue.className = "b06-structure-panel__hval";
heightRow.controls.append(
makeButton(
"−",
"측벽 높이 0.1m 낮추기",
act((role) => deps.nudgeHeight(role, -STEP_M)),
),
heightValue,
makeButton(
"+",
"측벽 높이 0.1m 높이기",
@@ -98,103 +93,6 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
),
);
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");
@@ -226,18 +124,23 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
),
);
root.append(
value,
heightRow.row,
pipeRow.row,
wingRow.row,
...wingValueRows.map((entry) => entry.row),
moveRow.row,
);
root.append(value, opts?.dock ? heightRow.row : moveRow.row);
const arrows = opts?.dock
? { attach: (): void => undefined, detach: (): void => undefined }
: bindArrowKeys(root, () => [moveRow.controls]);
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 같은 동작(24-4a).
const drag = opts?.dock
? { attach: (): void => undefined, detach: (): void => undefined }
: bindDragHandle(root, {
stepM: () => (current ? STEP_M : null),
preview: (text) => (dragLine.textContent = text),
commit: (left, down) => {
if (!current) return;
if (left) deps.nudge(current, left * STEP_M);
if (down) deps.nudgeSlope(current, down * STEP_M);
},
});
function render(): void {
if (!current) return;
@@ -248,16 +151,7 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
`높이 ${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);
}
heightValue.textContent = `${deps.heightFor(current).toFixed(1)}m`;
}
return {
@@ -265,13 +159,17 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
show(role) {
current = role;
root.classList.toggle("is-hidden", role === null);
// 좌측 [횡단 조정]에 어느 칸을 세울지 알린다 — 세월교는 그 측 날개벽 칸이다
// 좌측 폼에 어느 칸을 세울지 알린다 — 세월교는 그 측 날개벽 칸이다
// (배관 조정창이 유입구·유출구 칸을 가리키는 것과 같은 규약, 2026-08-30 지시 1).
root.dataset.side = role ?? "";
// 칸 이름이 이미 "날개벽(유입)"이라 괄호에는 구조물 이름만 붙인다.
root.dataset.panelTitle = role === null ? "" : "세월교";
arrows.detach();
if (role !== null) arrows.attach();
drag.detach();
if (role !== null) {
arrows.attach();
drag.attach();
}
render();
},
};
@@ -283,7 +181,7 @@ export interface FordControl {
update: (chainageM: number, role: FordWallRole, patch: Partial<FordWallAdjust>) => void;
reset: (chainageM: number, role: FordWallRole) => void;
/** 관경(mm)·수량(련)·관종·월류 폭 저장 — B05 정본(`pipe_points`)으로 간다.
* 관종·월류 폭은 조정창에 칸이 없고 좌측 「구조물 배치」 폼만 보낸다(2026-08-30 사용자). */
* 좌측 「구조물 배치」 폼만 보낸다(조정창에 칸 없음 · 계획서 24-4a). */
setPipe: (
chainageM: number,
patch: {
@@ -293,7 +191,7 @@ export interface FordControl {
ford_width_m?: number;
},
) => void;
/** 날개벽 제원 저장(2026-08-25 사용자 — 조정창에서 직접 제어). 같은 정본으로 간다. */
/** 날개벽 제원 저장 — 좌측 폼이 보낸다(계획서 24-4a). 같은 정본으로 간다. */
setWing: (
chainageM: number,
role: FordWallRole,
@@ -86,6 +86,104 @@ export function arrowsBelongToProfile(): boolean {
return !!lastPointer?.closest(".b05-route-profile");
}
/** 고른 구조물의 도형 — 벽 · 집수정 · 측벽은 `culvert` 계열, BOX 끝은 판정면이 켜진다. */
const ACTIVE_SHAPES = '[class*="culvert"].is-active, .b06-chart__box-hit.is-active';
/** 끌기 손잡이가 조정 창에 묻는 것 — 걸음(m)이 null 이면 지금 고른 것은 끌 수 없다. */
export interface DragHandleDeps {
stepM: () => number | null;
/** 끄는 동안 창에 적을 미리 보기(빈 글이면 지운다). */
preview: (text: string) => void;
/** 놓을 때 한 번 — 화면 왼쪽(◀) 걸음 수 · 아래(▼) 걸음 수. 둘 중 하나만 0 이 아니다. */
commit: (stepsLeft: number, stepsDown: number) => void;
}
/**
* 그림 위 손잡이(계획서 24-4a) — 고른 구조물 도형을 누른 채 끌면 십자와 같은 걸음으로 옮긴다.
* 끄는 동안은 도형만 옮겨 보이고(미리 보기) 놓을 때 한 번 반영한다 — 반영하면 카드가 다시 그려진다.
* 좌우 · 상하 중 많이 끈 쪽 하나만 쓴다(십자와 같음). 축척은 카드 도면이 적어 둔 `data-px-per-m`.
*/
export function bindDragHandle(
root: HTMLElement,
deps: DragHandleDeps,
): { attach: () => void; detach: () => void } {
const onDown = (event: PointerEvent): void => {
if (!root.isConnected) {
document.removeEventListener("pointerdown", onDown, true);
return;
}
if (event.button !== 0 || root.classList.contains("is-hidden")) return;
const svg = root.parentElement?.querySelector<SVGSVGElement>("svg.b06-section__chart");
const layer = svg?.querySelector<SVGGraphicsElement>(".b06-chart__plot-layer");
if (!svg || !layer || !svg.contains(event.target as Node)) return;
const shapes = [...svg.querySelectorAll<SVGGraphicsElement>(ACTIVE_SHAPES)];
const onShape = shapes.some((shape) => {
const box = shape.getBoundingClientRect();
return (
event.clientX >= box.left - 3 &&
event.clientX <= box.right + 3 &&
event.clientY >= box.top - 3 &&
event.clientY <= box.bottom + 3
);
});
const stepM = deps.stepM();
const pxPerM = Number(svg.dataset.pxPerM);
const exaggeration = Number(svg.dataset.exaggeration) || 1;
const ctm = layer.getScreenCTM();
if (!onShape || !stepM || !(pxPerM > 0) || !ctm) return;
event.preventDefault();
event.stopPropagation();
const [startX, startY] = [event.clientX, event.clientY];
let left = 0;
let down = 0;
const move = (next: PointerEvent): void => {
const dx = next.clientX - startX;
const dy = next.clientY - startY;
const horizontal = Math.abs(dx) >= Math.abs(dy);
left = horizontal ? Math.round(-dx / (pxPerM * ctm.a) / stepM) : 0;
down = horizontal ? 0 : Math.round(dy / (pxPerM * exaggeration * ctm.d) / stepM);
const tx = -left * stepM * pxPerM;
const ty = down * stepM * pxPerM * exaggeration;
shapes.forEach((shape) => shape.setAttribute("transform", `translate(${tx} ${ty})`));
const meters = (Math.abs(left || down) * stepM).toFixed(1);
deps.preview(
left
? `${left > 0 ? "◀" : "▶"} ${meters}m`
: down
? `${down > 0 ? "▼" : "▲"} ${meters}m`
: "",
);
};
const up = (): void => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", up);
shapes.forEach((shape) => shape.removeAttribute("transform"));
deps.preview("");
if (!left && !down) return;
// 놓은 자리의 click 이 「같은 구조물 다시 누름 = 선택 해제」로 번지지 않게 한 번 막는다.
const swallow = (click: Event): void => click.stopPropagation();
window.addEventListener("click", swallow, { capture: true, once: true });
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
deps.commit(left, down);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
};
return {
attach: () => document.addEventListener("pointerdown", onDown, true),
detach: () => document.removeEventListener("pointerdown", onDown, true),
};
}
/** 끄는 동안 쓰는 미리 보기 글 자리 — 창 읽기 줄 아래. */
export function dragReadout(): HTMLSpanElement {
const line = document.createElement("span");
line.className = "b06-structure-panel__drag";
return line;
}
const ARROW_SLOT: Record<string, string> = {
ArrowUp: "up",
ArrowDown: "down",
@@ -8,6 +8,10 @@
* 높이 ±0.1m(재질 한계), 재질(메2.0/찰3.0/콘크리트5.0). 다단은 단 수 숫자++/-.
*
* 창은 그래프 영역(`.b06-cross-card__chart-wrap`) 안 **좌측 하단**에 얹힌다.
*
* 값 칸은 좌측 폼 하나(계획서 24-4a) — 오버레이 창 = 읽기 · 십자 · 그림 끌기 손잡이만.
* 폼에 없는 값(단 수 · 옵션 · 다단 벽 · 집수정 내공 · 읽기 줄)은 사본(`dock`)이 줄로 만들어
* 폼 칸에 옮겨 붙인다(`_Adjust_Dock`). 폼과 같은 값(형태 · 높이 · 길이 · 전 · 후)은 만들지 않는다.
* ========================================================================== */
import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert";
@@ -15,7 +19,13 @@ import { REVET_FORMS, revetHeightLimit } from "./B06_Section_UI_Cross_Culvert_Co
import type { BasinAdjust, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import type { SpanValues } from "./B06_Section_UI_Cross_Culvert_Wire";
import { L } from "./B06_Section_UI_Section_Common";
import { arrowsBelongToProfile, makeButton, makeRow } from "./B06_Section_UI_Cross_Panel_Base";
import {
arrowsBelongToProfile,
bindDragHandle,
dragReadout,
makeButton,
makeRow,
} from "./B06_Section_UI_Cross_Panel_Base";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
export interface StructurePanelDeps {
@@ -40,8 +50,6 @@ export interface StructurePanelDeps {
close: () => void;
/** 유입측 구조물 형식(드롭다운 값 — 2026-08-22 사용자). */
structureFor: () => InletStructureChoice;
/** 유입측 구조물 형식 변경 — 카드를 다시 그린다. */
setStructure: (value: InletStructureChoice) => void;
basinAdjustFor: () => BasinAdjust;
updateBasin: (patch: Partial<BasinAdjust>) => void;
/** 집수정 좌우 — **화면 기준**(+ = 화면 왼쪽). 부호 환산·한계 토스트는 카드가 한다. */
@@ -58,8 +66,6 @@ export interface StructurePanelDeps {
canNudge: (key: RevetKey) => boolean;
/** 좌우·상하 걸음이 0.1m인가(독립·추가 기슭막이). 배관 기준벽은 false = 1m. */
fineMove: (key: RevetKey) => boolean;
/** 상황에 안 맞는 선택지 숨김(2026-08-22 사용자) — 기하가 판정한 가용성. */
optionsFor: () => { revetAllowed: boolean; basinLUAllowed: boolean };
/** 다단 기슭막이 상태 — 유출 벽 선택 시 단 수 행 표시 판단에 쓴다. */
extraState: () => { canAdd: boolean; count: number };
/** 이 카드가 연동으로 넘어온 링크 카드인가 — 단 수는 **기준(소유) 측점**에서만
@@ -152,10 +158,13 @@ export function buildStructurePanel(
// 정보 표시(2026-08-22 사용자 — 항목당 한 행): 관 길이 / 좌우 / 상하.
const value = document.createElement("div");
value.className = "b06-structure-panel__value";
// 사본의 읽기 줄은 폼 칸으로 옮겨 붙는다 — 끌어 맞춘 좌우 · 상하가 폼에서도 보인다(24-4a).
if (opts?.dock) value.classList.add("b06-structure-panel__struct--readout");
const pipeLine = document.createElement("span");
const lateralLine = document.createElement("span");
const slopeLine = document.createElement("span");
value.append(pipeLine, lateralLine, slopeLine);
const dragLine = dragReadout();
value.append(pipeLine, lateralLine, slopeLine, dragLine);
let current: RevetKey | null = null;
const act = (run: (key: RevetKey) => void) => () => {
@@ -253,37 +262,14 @@ export function buildStructurePanel(
);
heightParts.controls.append(heightMinus, heightValue, heightPlus);
// 유입측 구조물 형식 드롭다운(2026-08-22 사용자) — 자동/기슭막이/집수정 I·ㄴ·ㄷ.
const structureParts = makeRow(L("B06_Cross_Struct_Label"));
const structureRow = structureParts.row;
// 유입구 "구조" 드롭다운으로 병합됐다 — 사본에서는 숨긴다(2026-08-29 지시 5).
structureRow.classList.add("b06-structure-panel__struct--inlet-kind");
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);
// 유입측 구조물 형식은 좌측 유입구 「구조」 하나가 고른다(24-4a) — 창에는 칸을 두지 않는다.
// 집수정 조작 = **9키(3×3)**(2026-08-22 사용자 확정) — 십자 축은 이동, 네 모서리는
// 크기, 가운데는 초기화. 기슭막이 십자와 축 규칙이 같아 따로 익힐 게 없다.
// ⇕+ 사면▲ ⇔+
// 집수정 조작 = 십자(위치)만 창에 — 내공 크기(옛 9키 네 모서리)는 폼 칸으로 옮긴
// 「내공 폭 · 높이」 줄이 맡는다(24-4a). 가운데는 초기화.
// 사면▲
// ◀ ↺ ▶
// ⇕- 사면▼ ⇔-
const basinRow = makeRow("집수정 크기·위치");
// 사면▼
const basinRow = makeRow("집수정 위치");
basinRow.controls.classList.add("b06-structure-panel__basin-buttons");
const basinKey = (
label: string,
@@ -296,56 +282,49 @@ export function buildStructurePanel(
return button;
};
const basinPatch = (patch: () => Partial<BasinAdjust>) => () => deps.updateBasin(patch());
/** 집수정 사면 이동 — 걸음 수(+ = 아래). 십자 · 끌기가 같이 쓴다. */
const moveBasinSlope = (steps: number): void =>
deps.updateBasin({
slopeM: Math.max(
0,
Math.round((deps.basinAdjustFor().slopeM + steps * BASIN_STEP_M) * 10) / 10,
),
});
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("▲", "up", "1:1.2 성토선을 따라 위로 0.1m", () => moveBasinSlope(-1)),
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),
})),
),
basinKey("▼", "down", "1:1.2 성토선을 따라 아래로 0.1m", () => moveBasinSlope(1)),
);
// 집수정 내공 폭 · 높이(ㄴ · ㄷ형) — 옛 9키 모서리. 폼 유입구 칸으로 옮겨 붙는 줄(24-4a).
const basinSizeRows = (
[
["내공 폭", "innerWidthM"],
["내공 높이", "innerHeightM"],
] as const
).map(([label, field]) => {
const parts = makeRow(label);
parts.row.classList.add("b06-structure-panel__struct--basin-size");
const readout = document.createElement("span");
readout.className = "b06-structure-panel__hval";
// 한계 1~2m 는 옛 9키 그대로.
const step = (delta: number) =>
basinPatch(() => ({
[field]: Math.min(
2,
Math.max(1, Math.round((deps.basinAdjustFor()[field] + delta) * 10) / 10),
),
}));
parts.controls.append(
makeButton("-", `${label} ${BASIN_STEP_M}m 줄이기`, step(-BASIN_STEP_M)),
readout,
makeButton("+", `${label} ${BASIN_STEP_M}m 늘리기`, step(BASIN_STEP_M)),
);
return { row: parts.row, readout, field };
});
// 다단 기슭막이 단 수(2026-08-22 사용자) — 높이 행과 같은 형식(- 값 +),
// 직접 입력 없음. 현재 단 수는 show()가 채운다.
//
@@ -357,6 +336,8 @@ export function buildStructurePanel(
const extraRow = extraParts.row;
// 유입구·유출구 그룹으로 옮겨 붙는 행(2026-08-29 지시 4).
extraRow.classList.add("b06-structure-panel__struct--tier");
// 독립 기슭막이 레지스트리 「단 수」(tiers)를 끄면 폼 칸에서 이 줄도 가린다(옵션 보이기 · 24-4a).
extraRow.dataset.optionKeys = "tiers";
const countValue = document.createElement("span");
countValue.className = "b06-structure-panel__hval";
let currentCount = 0;
@@ -483,29 +464,54 @@ export function buildStructurePanel(
const basinReset = makeButton("↺", "집수정 기본값", () => deps.resetBasin());
basinReset.classList.add("b06-structure-panel__basin-reset");
head.append(title, basinReset, closeButton);
// 오버레이 = 읽기 + 십자(기슭막이) · 위치 십자(집수정)만 — 값 칸이 없다(24-4a). 두 조작
// 세트는 **창 맨 아래**(2026-08-24 사용자) · 동시에 뜨지 않아 같은 자리에 온다.
// 사본 = 폼 칸으로 옮겨 붙을 줄만(`_Adjust_Dock`) — 십자는 오버레이 전용(2026-08-29 지시 7).
root.append(
head,
value,
structureRow,
materialRow,
heightRow,
extraRow,
...spanRows.map((entry) => entry.row),
optionParts.row,
// 십자(기슭막이)·9키(집수정) 조작 세트는 **패널 맨 아래**에 모은다(2026-08-24
// 사용자). 둘은 동시에 뜨지 않으므로 어느 쪽이 보이든 같은 자리에 온다.
basinRow.row,
moveRow.row,
...(opts?.dock
? [
materialRow,
heightRow,
extraRow,
...spanRows.map((entry) => entry.row),
optionParts.row,
...basinSizeRows.map((entry) => entry.row),
]
: [basinRow.row, moveRow.row]),
);
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 같은 동작(24-4a). 오버레이만.
let dragKind: "wall" | "basin" | null = null;
const drag = bindDragHandle(root, {
stepM: () =>
!current || !dragKind ? null : dragKind === "basin" ? BASIN_STEP_M : moveStepOf(current),
preview: (text) => (dragLine.textContent = text),
commit: (left, down) => {
if (!current) return;
if (dragKind === "basin") {
if (left) deps.nudgeBasinLateral(left * BASIN_STEP_M);
if (down) moveBasinSlope(down);
return;
}
if (left) deps.nudge(current, left * moveStepOf(current));
if (down) deps.nudgeSlope(current, down * moveStepOf(current));
},
});
return {
root,
show: (key) => {
current = key;
root.classList.toggle("is-hidden", key === null);
document.removeEventListener("keydown", onArrowKey);
drag.detach();
if (!key) return;
if (!opts?.dock) document.addEventListener("keydown", onArrowKey);
if (!opts?.dock) {
document.addEventListener("keydown", onArrowKey);
drag.attach();
}
// 다단 벽 — 유출측(extra0…)과 유입·집수정 계류측(bextra0…)이 같은 이름을 쓴다.
const isExtra = key.startsWith("extra") || key.startsWith("bextra");
const extraIndex = key.startsWith("bextra") ? Number(key.slice(6)) : Number(key.slice(5));
@@ -527,23 +533,8 @@ export function buildStructurePanel(
: movable
? L("B06_Cross_Revet_Inlet")
: L("B06_Cross_Struct_InletBasin");
// 독립 기슭막이(관 숨김)는 유입·유출이 같은 성토 벽이다 — 집수정 형식 선택만 숨긴다
// (단 수 행은 아래에서 기슭막이 기준벽이면 언제나 준다).
// 형식 선택은 유입측에서만, 이동·높이 조작은 기슭막이(집수정 제외)만.
structureRow.classList.toggle("is-hidden", key !== "inlet" || deps.hiddenPipe());
if (key === "inlet" && !deps.hiddenPipe()) {
// 상황에 안 맞는 선택지는 숨긴다(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;
}
// 이동 조작은 기슭막이(집수정 제외) · 집수정은 위치 십자.
dragKind = isBasin ? "basin" : movable ? "wall" : null;
moveRow.row.classList.toggle("is-hidden", !movable);
// 등간격(≡)은 **유출 채널 다단 전용**이다 — 유입 벽에서 누르면 남의 단을 옮긴다.
// 독립 기슭막이(옛 D경로 own)는 단 수·자리가 구조물 정본 몫이라 역시 숨긴다.
@@ -552,19 +543,19 @@ export function buildStructurePanel(
?.classList.toggle("is-hidden", isOwn || key !== "outlet");
basinRow.row.classList.toggle("is-hidden", !isBasin);
basinReset.classList.toggle("is-hidden", !isBasin);
// 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);
// I형은 내공(높이·폭) 개념이 없다 — 내공 줄을 숨긴다.
for (const entry of basinSizeRows) {
entry.row.classList.toggle("is-hidden", !isBasin || structure === "I");
if (isBasin)
entry.readout.textContent = `${deps.basinAdjustFor()[entry.field].toFixed(1)}m`;
}
// 재질은 배관 벽만 조정창에서 고른다 — 독립 기슭막이는 구조물 옵션(형태)이 정본이다.
materialRow.classList.toggle("is-hidden", !movable || isOwn);
heightRow.classList.toggle("is-hidden", !movable);
if (movable) {
// 형태 · 높이는 좌측 폼 칸이 정본이다(유입구 · 유출구 · 독립 좌우 · 구조물 옵션) — 폼에
// 자기 칸이 없는 **다단 벽**만 이 줄을 「추가 기슭막이」 칸에 세운다(24-4a).
materialRow.classList.toggle("is-hidden", !isExtra);
heightRow.classList.toggle("is-hidden", !isExtra);
if (isExtra) {
heightValue.textContent = `${deps.heightFor(key).toFixed(1)}m`;
if (!isOwn) materialSelect.value = deps.formFor(key);
materialSelect.value = deps.formFor(key);
}
// 단 수 행 — 벽 종류를 가리지 않고 **높이 행 바로 아래** 한 자리에 온다.
// 유출 벽은 자기 채널(extra), 유입 벽·집수정 계류측은 basin 채널(bextra)을 쓴다.
@@ -616,9 +607,10 @@ export function buildStructurePanel(
countValue.textContent = `${tiers.count}단`;
// 구간값 — 유입·유출 기슭막이, 유입 집수정, 그리고 **다단**(2026-08-29 사용자:
// 계곡부·능선부에서 단마다 연장이 달라 단별로 손수 넣는다)에서 세 행이 뜬다.
// 기준벽 · 집수정의 길이 · 전 · 후는 폼 칸(유입구 · 유출구)이 정본 — 줄은 다단 벽만(24-4a).
currentSpan = deps.spanFor(key);
for (const entry of spanRows) {
entry.row.classList.toggle("is-hidden", currentSpan === null);
entry.row.classList.toggle("is-hidden", currentSpan === null || !isExtra);
if (currentSpan) entry.value.textContent = `${currentSpan[entry.field].toFixed(1)}m`;
}
// 연동은 링크 카드에서만, 경사는 구간값이 있는 구조물에서만. 둘 다 없으면 행 자체를 숨긴다.
@@ -658,6 +650,20 @@ export function buildStructurePanel(
: 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`
: "0m");
// 집수정은 벽 값이 아니라 **집수정 자리**를 적는다 — 끌어 맞춘 위치가 폼 칸에서도 보인다(24-4a).
if (isBasin) {
const basin = deps.basinAdjustFor();
lateralLine.textContent = `${L("B06_Cross_Lateral_Label")} ${
basin.lateralM > 1e-9
? `${L("B06_Cross_Revet_Outward")} ${basin.lateralM.toFixed(1)}m`
: L("B06_Cross_Revet_Auto")
}`;
slopeLine.textContent = `${L("B06_Cross_Slope_Label")} ${
basin.slopeM > 1e-9
? `${L("B06_Cross_Revet_DirDown")} ${basin.slopeM.toFixed(1)}m`
: L("B06_Cross_Revet_Auto")
}`;
}
// 행 표시가 다 끝난 뒤에 되살린다 — 숨김 토글로 창 높이가 바뀌기 전에 넣으면
// 브라우저가 잘라 버린다.
restoreScroll();
+3
View File
@@ -313,6 +313,9 @@ export function createCrossSectionCard(
width: widthPx,
height: heightPx,
viewBox: `0 0 ${widthPx} ${heightPx}`,
// 조정 창 끌기 손잡이의 축척(계획서 24-4a · `bindDragHandle`).
"data-px-per-m": pixelsPerMeter,
"data-exaggeration": exaggeration,
role: "img",
"aria-label": `${section.label} ${L("B06_Profile_View_Cross")}`,
});
@@ -42,16 +42,6 @@ export function fordPanelDeps(context: FordPanelContext): FordPanelDeps {
slopeM: ford.adjustFor(chainage)[role].slopeM + deltaM,
}),
reset: (role) => ford.reset(chainage, role),
pipeDiameterMm: () => Math.round((section.ford?.diameter_m ?? 1) * 1000),
setPipeDiameterMm: (value) => ford.setPipe(chainage, { pipe_diameter_mm: value }),
pipeCount: () => section.ford?.pipe_count ?? 1,
setPipeCount: (value) => ford.setPipe(chainage, { pipe_count: value }),
wingFor: (role) => {
const spec = section.ford;
if (!spec) return null;
return role === "inlet" ? spec.wing_in : spec.wing_out;
},
setWing: (role, patch) => ford.setWing(chainage, role, patch),
slabLengthM: context.slabLengthM,
close: context.close,
};
@@ -109,7 +109,6 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
if (active) ctx.toggleRevet(active);
},
structureFor: () => ctx.inletStructure?.valueFor(ctx.section) ?? "auto",
setStructure: (value) => ctx.inletStructure?.set(ctx.adjustChainage(), value),
basinAdjustFor: () =>
ctx.inletStructure?.adjustFor(ctx.section) ?? {
innerWidthM: 1,
@@ -143,7 +142,6 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
// 걸음 분류(2026-08-29 사용자): **배관 기준벽만 1m**, 나머지(독립 기슭막이 기준벽·
// 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m.
fineMove: (key) => (key !== "inlet" && key !== "outlet") || ctx.hiddenPipe(),
optionsFor: () => ctx.inletOptions(),
extraState: () => ctx.extraState(),
isLinkedCard: () => ctx.isLinked,
basinExtraState: () => ctx.basinExtraState(),
+16 -11
View File
@@ -62,6 +62,11 @@ export function applyPipeOptionsToCache(
const put = <T extends object>(target: T, field: keyof T, value: unknown): void => {
if (value !== undefined) (target as Record<string, unknown>)[field as string] = value;
};
/** 폼 칸 값 — 폼이 보낸 것이 칸 전부라 비운 칸은 지운다(계획서 24-4a 빈 값). */
const own = <T extends object>(target: T, field: keyof T, value: unknown): void => {
if (value === undefined) delete (target as Record<string, unknown>)[field as string];
else (target as Record<string, unknown>)[field as string] = value;
};
put(culvert, "pipe_kind", patch.pipe_kind);
// 기슭막이 기초(기초유/기초버림) — **터파기 파선을 그리는 값**이다(`foundationChoice`).
// 옮겨 적지 않아 폼에서 고쳐도 도면이 다음 조회까지 안 따라왔다(2026-09-09 실측).
@@ -74,26 +79,26 @@ export function applyPipeOptionsToCache(
[culvert.inlet, "inlet"],
[culvert.outlet, "outlet"],
] as const) {
put(side, "revet_form", patch[`${prefix}_revet_form`]);
put(side, "revet_height_m", num(`${prefix}_revet_height_m`));
put(side, "revet_length_m", num(`${prefix}_revet_length_m`));
put(side, "revet_before_m", num(`${prefix}_revet_before_m`));
put(side, "revet_after_m", num(`${prefix}_revet_after_m`));
own(side, "revet_form", patch[`${prefix}_revet_form`]);
own(side, "revet_height_m", num(`${prefix}_revet_height_m`));
own(side, "revet_length_m", num(`${prefix}_revet_length_m`));
own(side, "revet_before_m", num(`${prefix}_revet_before_m`));
own(side, "revet_after_m", num(`${prefix}_revet_after_m`));
put(side, "structure", patch[`${prefix}_type`]);
}
// 배관 벽의 **높이·형태는 스펙이 아니라 조정값**(revet_adjust.h·m)이 정한다
// — `revetWallSpec`이 배관에서는 spec.revet_height_m을 쓰지 않기 때문이다.
// 폼에서 고친 값을 조정창과 같은 채널로 보내야 도면이 따라온다(2026-08-29 사용자).
// 높이 · 형태를 고치는 칸은 폼 하나라(24-4a) 비운 칸 = 자동(h · m 없음).
for (const [role, prefix] of [
["inlet", "inlet"],
["outlet", "outlet"],
] as const) {
const height = num(`${prefix}_revet_height_m`);
const form = patch[`${prefix}_revet_form`];
if (height === undefined && typeof form !== "string") continue;
ctx.revetOffset.update(owner.chainage_m, role, {
...(height !== undefined ? { h: height } : {}),
...(typeof form === "string" ? { m: form } : {}),
h: height ?? null,
m: typeof form === "string" ? form : null,
});
// 형태마다 높이 한계가 있다(돌쌓기(메) 2.0m 등). 기하가 잘라 낸 실제 높이를
// 폼에 되돌린다 — 숫자만 커지고 그림은 그대로인 상태를 남기지 않는다.
@@ -115,9 +120,9 @@ export function applyPipeOptionsToCache(
);
}
}
put(culvert.inlet, "basin_length_m", num("inlet_basin_length_m"));
put(culvert.inlet, "basin_before_m", num("inlet_basin_before_m"));
put(culvert.inlet, "basin_after_m", num("inlet_basin_after_m"));
own(culvert.inlet, "basin_length_m", num("inlet_basin_length_m"));
own(culvert.inlet, "basin_before_m", num("inlet_basin_before_m"));
own(culvert.inlet, "basin_after_m", num("inlet_basin_after_m"));
// 연장이 바뀌면 옆 측점 링크도 달라진다 — 그 구조물이 덮는 범위만 다시 그린다.
const reach = Math.max(
culvert.inlet.revet_before_m ?? 0,
@@ -29,6 +29,7 @@ import {
type StructureType,
} from "../B05_Profile/B05_Profile_Api_Structures";
import { readPendingPipes, writePendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { mergeFacilityOptions } from "../B05_Profile/B05_Profile_UI_Drainage_Facility_Merge";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { readSummaryItem } from "../common_util/common_util_summary_item";
import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
@@ -82,9 +83,6 @@ export interface B06StructuresPanelDeps {
focusChainage: (chainageM: number) => void;
/** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */
onMarks?: (structures: StructureInstance[], types: StructureType[]) => void;
/** 폼 [수정]으로 바뀐 관 옵션을 캐시에 예약한다 — [저장]·[확정]이 정본에 쓴다
* (2026-08-29: 조정창 구간값과 같은 경로). 없으면 안내만 한다. */
queuePipeOptions?: (chainageM: number, patch: Record<string, number | string>) => void;
/** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 —
* 조정창과 같은 흐름이라 폼 조작도 도면에 바로 보인다(2026-08-29 사용자 보고). */
applyPipeOptions?: (chainageM: number, patch: Record<string, number | string>) => void;
@@ -211,22 +209,24 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
deps.onStructuresChanged?.();
}
}
const patch = attributes.options;
if (!patch || !Object.keys(patch).length) return;
if (!deps.queuePipeOptions) {
// 폼 값은 **폼 칸 전부**다 — 비운 칸은 지운 것(계획서 24-4a · B05 [저장]과 같은 규칙).
const patch = (attributes.options ?? {}) as Record<string, number | string>;
if (!deps.projectId) {
showToast(PIPE_ADD_GUIDE, "error");
return;
}
// 단 수는 옵션이자 **조작 채널**이다 — 조정창에서 세우던 그 자리로 보내야
// 횡단도에 단이 선다(2026-08-30 사용자 지시 3: 배수관 측점과 동일하게).
// 옵션은 **옮기기 전 자리**로 예약한다 — 저장 시점의 관 목록이 그 자리 기준이다.
deps.queuePipeOptions(fromChainageM, patch as Record<string, number | string>);
deps.applyPipeOptions?.(fromChainageM, patch as Record<string, number | string>);
// 화면 캐시(목록·폼 재로드용)도 같이 맞춘다 — 저장 전에도 값이 유지된다.
// 값은 관 목록 초안(가게 pipes) 하나로 나간다 — 조정 예약(culvertopt)에 따로 담으면 비운 칸이
// [저장] 때 옛 값으로 되살아난다. 옮기기 전 자리로 찾는다(저장 시점 목록 기준).
deps.applyPipeOptions?.(fromChainageM, patch);
const hit = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
);
if (hit) hit.options = { ...(hit.options ?? {}), ...patch };
if (hit) {
const facility = hit.facility ?? "pipe";
hit.options =
mergeFacilityOptions({ facility, options: hit.options }, { facility, options: patch })
.options ?? {};
}
persistPipes();
section.setPipeFacilities(pipeFacilities);
},
@@ -70,6 +70,16 @@
font-size: var(--text-caption);
}
/* 그림 끌기 미리 보기(계획서 24-4a) — 끄는 동안만 한 줄. */
.b06-structure-panel__drag {
color: var(--color-primary);
font-weight: var(--font-weight-medium);
}
.b06-structure-panel__drag:empty {
display: none;
}
.b06-structure-panel__controls.b06-structure-panel__buttons {
display: grid;
grid-template-areas: ". up ." "left reset right" ". down equal";
@@ -0,0 +1,252 @@
/* 횡단 조정 창 · 좌측 폼 한 곳 단위검증 헬퍼(PLAN 24-4a) — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다.
* 화면 그릇(DOM)은 조정 창이 쓰는 만큼만 흉내낸다. 결과는 표준출력에 JSON 한 벌.
*
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
// ── 화면 그릇 흉내 — 요소 · 클래스 · 이벤트 · 글자 · `.클래스` 찾기만 ────────────
class FakeNode {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = [];
this.dataset = {};
this.style = {};
this.listeners = {};
this._text = "";
this._classes = new Set();
this.value = "";
const self = this;
this.classList = {
add: (...names) => names.forEach((n) => self._classes.add(n)),
remove: (...names) => names.forEach((n) => self._classes.delete(n)),
toggle: (name, force) => {
const on = force === undefined ? !self._classes.has(name) : !!force;
on ? self._classes.add(name) : self._classes.delete(name);
return on;
},
contains: (name) => self._classes.has(name),
};
}
set className(value) {
this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
}
get className() {
return [...this._classes].join(" ");
}
set textContent(value) {
this._text = String(value);
this.children = [];
}
get textContent() {
return this._text + this.children.map((c) => c.textContent).join("");
}
append(...kids) {
kids.forEach((k) => this.children.push(k));
}
setAttribute(name, value) {
this[name] = value;
}
addEventListener(type, handler) {
(this.listeners[type] ||= []).push(handler);
}
getClientRects() {
return [1];
}
get isConnected() {
return true;
}
querySelectorAll(selector) {
const found = [];
const walk = (node) =>
node.children.forEach((kid) => {
if (kid.classList && kid.classList.contains(selector.slice(1))) found.push(kid);
walk(kid);
});
walk(this);
return found;
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] ?? null;
}
}
globalThis.document = {
createElement: (tag) => new FakeNode(tag),
addEventListener: () => {},
removeEventListener: () => {},
};
globalThis.window = { addEventListener: () => {}, removeEventListener: () => {} };
globalThis.requestAnimationFrame = (fn) => fn();
const STUBS = {
"./B06_Section_UI_Section_Common": { L: (key) => key },
"@ui/ui_template_elements": { showToast: () => {} },
"./B06_Section_UI_Page_Ford_Controls": {
applyFordFormOptions: () => {},
applyBoxFormOptions: () => {},
},
};
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.startsWith("@util/"))
return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`));
return loadTs(path.join(path.dirname(full), `${request}.ts`));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const B06 = (name) => loadTs(path.join(ROOT, "B06_Section", `${name}.ts`));
const { buildStructurePanel } = B06("B06_Section_UI_Cross_Structure_Panel");
const { buildFordPanel } = B06("B06_Section_UI_Cross_Ford_Panel");
const { buildBoxPanel } = B06("B06_Section_UI_Cross_Box_Panel");
const { applyPipeOptionsToCache } = B06("B06_Section_UI_Page_Pipe_Options");
/** 값 칸(input · select) 수 — 조정 창 완료 기준 수치. */
const fieldCount = (node) =>
node.children.reduce(
(sum, kid) =>
sum + (kid.tagName === "INPUT" || kid.tagName === "SELECT" ? 1 : 0) + fieldCount(kid),
0,
);
/** 보이는 줄의 종류(`__struct--*`) — 숨김(is-hidden) 줄은 뺀다. */
const shownRows = (root) =>
root.children
.filter((kid) => !kid.classList.contains("is-hidden"))
.flatMap((kid) =>
[...kid._classes].filter((c) => c.startsWith("b06-structure-panel__struct--")),
)
.map((c) => c.replace("b06-structure-panel__struct--", ""))
.sort();
let structure = "revet";
const wallDeps = {
adjustFor: () => ({ x: 0, d: null, h: null, m: null }),
nudge: () => {},
nudgeSlope: () => {},
nudgeHeight: () => {},
heightFor: () => 2,
formFor: () => "돌쌓기(메)",
setForm: () => {},
reset: () => {},
pipeLengthM: () => 10,
close: () => {},
structureFor: () => structure,
basinAdjustFor: () => ({ innerWidthM: 1, innerHeightM: 1.2, lateralM: 0, slopeM: 0 }),
updateBasin: () => {},
nudgeBasinLateral: () => {},
resetBasin: () => {},
basinExtraState: () => ({ canAdd: false, count: 0 }),
setBasinExtraCount: () => {},
hiddenPipe: () => false,
canNudge: (key) => key !== "inlet" || structure === "revet",
fineMove: () => false,
extraState: () => ({ canAdd: true, count: 1 }),
isLinkedCard: () => false,
setExtraCount: () => {},
equalizeExtras: () => {},
spanFor: () => ({ lengthM: 10, beforeM: 5, afterM: 5 }),
setSpan: () => {},
linkState: () => null,
setLinked: () => {},
followGrade: () => true,
setFollowGrade: () => {},
scrollKey: "t",
};
const out = { overlay: {}, dock: {} };
for (const [label, key, kind] of [
["outlet", "outlet", "revet"],
["extra", "extra0", "revet"],
["basin", "inlet", "L"],
]) {
structure = kind;
const overlay = buildStructurePanel(wallDeps);
overlay.show(key);
const dock = buildStructurePanel(wallDeps, { dock: true });
dock.show(key);
out.overlay[label] = fieldCount(overlay.root);
out.dock[label] = shownRows(dock.root);
}
const fordDeps = {
adjustFor: () => ({ heightM: null, lateralM: 0, slopeM: 0 }),
heightFor: () => 1.5,
nudgeHeight: () => {},
nudge: () => {},
nudgeSlope: () => {},
reset: () => {},
slabLengthM: () => 7.6,
close: () => {},
};
const fordOverlay = buildFordPanel(fordDeps);
fordOverlay.show("inlet");
const fordDock = buildFordPanel(fordDeps, { dock: true });
fordDock.show("inlet");
out.overlay.ford = fieldCount(fordOverlay.root);
out.dock.ford = shownRows(fordDock.root);
const boxDeps = {
adjustFor: () => ({ lengthM: 0, riseM: 0 }),
nudgeLength: () => {},
nudgeRise: () => {},
reset: () => {},
wingRoleFor: () => "inlet",
bodyLengthM: () => 5,
slopeRatio: () => null,
close: () => {},
};
const boxOverlay = buildBoxPanel(boxDeps);
boxOverlay.show("left");
const boxDock = buildBoxPanel(boxDeps, { dock: true });
boxDock.show("left");
out.overlay.box = fieldCount(boxOverlay.root);
out.dock.box = shownRows(boxDock.root);
// ── 폼 → 횡단 캐시: 폼이 보낸 것이 칸 전부 — 비운 칸은 자동으로(빈 값) ──────────
const updates = [];
const section = {
chainage_m: 100,
culvert: {
diameter_m: 1,
hidden_pipe: false,
inlet: { revet_height_m: 3, revet_length_m: 10 },
outlet: { revet_height_m: 3, revet_form: "콘크리트" },
},
};
const ctx = {
detail: () => ({ cross_sections: [section] }),
ford: {},
box: {},
revetOffset: {
update: (chainageM, role, patch) => updates.push({ role, ...patch }),
adjustFor: () => ({ x: 0, d: null, h: null, m: null }),
},
overrideOptions: () => {},
refreshCard: () => {},
};
applyPipeOptionsToCache(ctx, 100, { outlet_revet_height_m: 2.5, outlet_revet_form: "돌쌓기(메)" });
const filled = updates.splice(0);
applyPipeOptionsToCache(ctx, 100, {});
out.cache = {
filled,
cleared: updates,
outletSpec: section.culvert.outlet,
inletSpec: section.culvert.inlet,
};
process.stdout.write(JSON.stringify(out));
@@ -34,7 +34,7 @@ PIPE_BEFORE = {
"inlet_type": "집수정",
"inlet_basin_form": "□형(기본형)",
"revet_foundation": "기초유", # 집계표로 적은 칸 — 폼에 없음
"inlet_basin_before_m": 1.5, # 횡단(B06)이 적는 칸 — 폼에 없음
"inlet_basin_before_m": 1.5, # 집수정 전 — 24-4a 부터 폼 칸(옛 횡단 조정창 몫)
},
}
# 폼이 다시 저장 — 유입구를 기슭막이로 바꿔 집수정 칸은 안 보냄
@@ -94,7 +94,7 @@ def merged(tmp_path_factory) -> dict:
def test_폼이_모르는_칸은_남고_폼이_아는_칸은_폼대로(merged) -> None:
options = merged["out"][0]["options"]
assert options["revet_foundation"] == "기초유" # 집계표 값이 살아남음
assert options["inlet_basin_before_m"] == 1.5
assert "inlet_basin_before_m" not in options # 폼 칸(24-4a) — 집수정을 걷으면 함께 지움
assert options["pipe_diameter_mm"] == 800 # 폼 값이 이김
assert "inlet_basin_form" not in options # 폼이 아는 칸을 폼이 비움 → 지움
assert merged["out"][1]["options"] == {"side": "좌", "back_len_cm": "35"}
@@ -0,0 +1,93 @@
"""횡단 카드 조정 창 · 좌측 폼 — 같은 값을 고치는 칸이 한 곳뿐인가 (PLAN 24-4a).
짝: `B06_Section/B06_Section_UI_Cross_Structure_Panel.ts` · `_Ford_Panel.ts` · `_Box_Panel.ts`
(조정 창 · 폼 칸에 옮겨 붙는 사본 줄) · `B06_Section_UI_Page_Pipe_Options.ts`(폼 → 횡단 캐시).
실제 TS 를 `helper_b06_adjust_single_form.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
import pytest
HERE = os.path.dirname(__file__)
ROOT = Path(HERE).resolve().parents[1]
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_b06_adjust_single_form.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_조정_창에는_값_칸이_없다(out) -> None:
"""옹벽(배관 벽 · 다단 벽 · 집수정) · 세월교 · BOX 조정 창의 input · select = 0."""
assert out["overlay"] == {"outlet": 0, "extra": 0, "basin": 0, "ford": 0, "box": 0}
def test_폼과_같은_값은_사본_줄로_만들지_않는다(out) -> None:
"""형태 · 높이 · 길이 · 전 · 후 · 관경 · 날개벽은 폼 칸 — 사본은 폼에 없는 줄만."""
assert out["dock"]["outlet"] == ["options", "readout", "tier"]
assert out["dock"]["basin"] == ["basin-size", "basin-size", "options", "readout"]
assert out["dock"]["ford"] == ["readout", "wall-height"]
assert out["dock"]["box"] == ["readout"]
def test_다단_벽은_폼에_자기_칸이_없어_줄이_간다(out) -> None:
assert out["dock"]["extra"] == [
"form",
"height",
"options",
"readout",
"span",
"span",
"span",
"tier",
]
def test_폼_값이_그림으로_가고_비운_칸은_자동(out) -> None:
cache = out["cache"]
assert {"role": "outlet", "h": 2.5, "m": "돌쌓기(메)"} in cache["filled"]
assert cache["cleared"] == [
{"role": "inlet", "h": None, "m": None},
{"role": "outlet", "h": None, "m": None},
]
assert cache["outletSpec"] == {} and cache["inletSpec"] == {}
def test_사본_줄은_모두_폼_칸으로_옮겨_붙는다() -> None:
"""사본이 만드는 줄 종류가 옮김 목록에 다 있어야 [횡단 조정] 상자 없이도 보인다."""
dock = (ROOT / "B06_Section" / "B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8")
for row in (
"readout",
"tier",
"options",
"basin-size",
"wall-height",
"form",
"height",
"span",
):
assert f".b06-structure-panel__struct--{row}" in dock, row
assert "dockRoot.hidden = true" in dock
def test_폼_옵션은_조정_예약으로_따로_담지_않는다() -> None:
"""관 옵션은 가게(pipes) 하나 — 조정 예약(culvertopt)에 담으면 비운 칸이 [저장] 때 되살아난다."""
panel = (ROOT / "B06_Section" / "B06_Section_UI_Page_Structures_Panel.ts").read_text(
encoding="utf-8"
)
assert "queuePipeOptions" not in panel
assert "mergeFacilityOptions(" in panel