feat(B06): 횡단도 안 조정창 = 제목 · 단추만 · 그림 끌기 두 축 함께 + 놓을 때 패널 걸음 맞춤 · 미리 보기 = 맞춘 자리(108-1 · 108-2)

- 조정창(오버레이): 읽기 줄 · 줄 이름 · 머리줄 집수정 ↺ 지움 · 끄는 중 미리 보기 글만 · 좌측 사본 그대로
- 끌기: 벽 · 집수정 · 세월교 = 수평 + 1:1.2 사면 · 다단 · 독립 벽 = 수평 + 수직 · BOX = 길이 + 표고
- 한계(±10 m · 노견 안쪽 · 노면 위 · 다단 선반 · BOX 길이 0)면 가장 가까운 허용 자리 · 반영 한 번
- 시험 test_108_drag_box_hit(끌기 · 걸음 · 미리 보기 = placePipeWall 결과 · 한계 · BOX 판정면)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
2026-10-08 16:22:21 +09:00
co-authored by Claude Opus 5.5
parent 90383cab29
commit 42d0d725a5
10 changed files with 654 additions and 69 deletions
+25 -9
View File
@@ -14,9 +14,11 @@ import {
bindArrowKeys,
bindDragHandle,
buildPanelShell,
clampAxis,
dpadButton,
dragReadout,
makeRow,
snapAxisDrag,
} from "./B06_Section_UI_Cross_Panel_Base";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
@@ -27,6 +29,8 @@ export interface BoxPanelDeps {
nudgeLength: (role: BoxSideRole, screenDeltaM: number) => void;
/** 그 끝의 구체 표고 ±0.1m. */
nudgeRise: (role: BoxSideRole, deltaM: number) => void;
/** 그림 끌기(108-2) — 길이(화면 좌 +) · 표고를 한 번에 반영. */
move: (role: BoxSideRole, screenDeltaM: number, riseDeltaM: number) => void;
/** 그 측을 자동 자리로 되돌린다. */
reset: (role: BoxSideRole) => void;
/** 이 측이 좌측 「구조물 배치」의 어느 칸인가 — 좌·우는 측점 지형이 정하고, 폼은
@@ -48,9 +52,10 @@ export interface BoxPanelHandle {
/** 한 걸음(m) — 사용자 지정 0.1m. */
const STEP_M = 0.1;
const RISE_LIMIT_M = 10;
/** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다. 값 칸은 없다(계획서 24-4a) —
* 오버레이 = 읽기 + 십자 + 그림 끌기 손잡이 · `opts.dock` = 좌측 폼 날개벽 칸으로 옮겨 붙는 읽기 줄. */
* 오버레이 = 제목 + 십자 + 그림 끌기 손잡이(108-1) · `opts.dock` = 좌측 폼 날개벽 칸으로 옮겨 붙는 읽기 줄. */
export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): BoxPanelHandle {
const shell = buildPanelShell(() => deps.close());
const { root, title } = shell;
@@ -63,7 +68,8 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo
const riseLine = document.createElement("span");
const slopeLine = document.createElement("span");
const dragLine = dragReadout();
value.append(lengthLine, riseLine, slopeLine, dragLine);
// 횡단도 안 창 = 제목 · 단추 · 끄는 중 미리 보기만(108-1) — 읽기 줄은 좌측 사본에만.
value.append(...(opts?.dock ? [lengthLine, riseLine, slopeLine] : []), dragLine);
let current: BoxSideRole | null = null;
const act = (run: (role: BoxSideRole) => void) => () => {
@@ -106,17 +112,27 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo
const off = { attach: (): void => undefined, detach: (): void => undefined };
const arrows = opts?.dock ? off : bindArrowKeys(root, () => [moveRow.controls]);
// 그림 위 끌기 손잡이 — ◀▶ 구체 길이 · ▲▼ 표고(십자와 같은 걸음 · 24-4a).
// 그림 위 끌기 손잡이 — ◀▶ 구체 길이 · ▲▼ 표고 두 축 함께(십자와 같은 걸음 · 108-2).
// 한계 = 길이 ≥ 0(바깥으로만) · 표고 ±10 m(제어기 `_Page_Ford_Controls` 와 같음).
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);
snap: (leftM, downM) => {
const role = current;
if (!role) return null;
const from = deps.adjustFor(role);
const lengthSign = role === "left" ? 1 : -1;
return snapAxisDrag(leftM, downM, {
stepM: STEP_M,
slopeOutward: null,
clamp: (left, down) => [
clampAxis(from.lengthM, left * lengthSign, 0, Infinity) * lengthSign,
-clampAxis(from.riseM, -down, -RISE_LIMIT_M, RISE_LIMIT_M),
],
apply: (left, down) => deps.move(role, left, -down),
});
},
preview: (text) => (dragLine.textContent = text),
});
function render(): void {
+28 -9
View File
@@ -17,9 +17,11 @@ import {
bindArrowKeys,
bindDragHandle,
buildPanelShell,
clampAxis,
dpadButton,
dragReadout,
makeRow,
snapAxisDrag,
} from "./B06_Section_UI_Cross_Panel_Base";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
@@ -32,6 +34,10 @@ export interface FordPanelDeps {
nudge: (role: FordWallRole, screenDeltaM: number) => void;
/** 상하: 1:1.2 사면을 타는 대각 이동(수평 성분 m, + = 사면 아래). */
nudgeSlope: (role: FordWallRole, deltaM: number) => void;
/** 사면 바깥이 화면 왼쪽이면 1 · 오른쪽이면 −1(끌기 축 나누기 · 108-2). */
outwardOf: (role: FordWallRole) => number;
/** 그림 끌기 — 좌우(화면 좌 +) · 상하(사면) 를 한 번에 반영. */
move: (role: FordWallRole, screenDeltaM: number, slopeDeltaM: number) => void;
/** 자동 자리로 되돌린다(세 축 모두). */
reset: (role: FordWallRole) => void;
/** 바닥판 길이(m) — 날개벽 투영이 정한 값을 읽기 전용으로 보여 준다. */
@@ -48,11 +54,13 @@ export interface FordPanelHandle {
/** 한 걸음 — 집수정 9키와 같은 0.1m 눈금. */
const STEP_M = 0.1;
/** 좌우 · 상하 한계(m) — 제어기 `clampMove`(`_Page_Ford_Controls`)와 같다. */
const MOVE_LIMIT_M = 10;
/** 세월교 측벽 조정 오버레이 창을 만든다. 처음에는 숨어 있다.
* 관경 · 수량 · 날개벽은 좌측 폼 칸이 정본이라 창에 두지 않는다(계획서 24-4a).
* `opts.dock` — 읽기 줄만 만든다(측벽 높이 줄 없음 · 107-3).
* 오버레이는 읽기 + 십자 + 그림 끌기 손잡이. */
* 오버레이는 제목 + 십자 + 그림 끌기 손잡이(108-1). */
export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): FordPanelHandle {
const shell = buildPanelShell(() => deps.close());
const { root, title } = shell;
@@ -65,7 +73,8 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
const moveLine = document.createElement("span");
const slabLine = document.createElement("span");
const dragLine = dragReadout();
value.append(heightLine, moveLine, slabLine, dragLine);
// 횡단도 안 창 = 제목 · 단추 · 끄는 중 미리 보기만(108-1) — 읽기 줄은 좌측 사본에만.
value.append(...(opts?.dock ? [heightLine, moveLine, slabLine] : []), dragLine);
let current: FordWallRole | null = null;
const act = (run: (role: FordWallRole) => void) => () => {
@@ -110,17 +119,27 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
const arrows = opts?.dock
? { attach: (): void => undefined, detach: (): void => undefined }
: bindArrowKeys(root, () => [moveRow.controls]);
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 같은 동작(24-4a).
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 좌우 + 1:1.2 사면 두 축 함께(108-2).
// 한계 = 좌우 0 ~ 10 m(노견 안쪽 금지) · 상하 ±10 m.
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);
snap: (leftM, downM) => {
const role = current;
if (!role) return null;
const from = deps.adjustFor(role);
const outward = deps.outwardOf(role);
return snapAxisDrag(leftM, downM, {
stepM: STEP_M,
slopeOutward: outward,
clamp: (left, down) => [
clampAxis(from.lateralM, left * outward, 0, MOVE_LIMIT_M) * outward,
clampAxis(from.slopeM, down, -MOVE_LIMIT_M, MOVE_LIMIT_M),
],
apply: (left, down) => deps.move(role, left, down),
});
},
preview: (text) => (dragLine.textContent = text),
});
function render(): void {
+76 -27
View File
@@ -10,6 +10,8 @@
* · 키보드 방향키 = 십자 버튼(2026-08-23) — 셀렉트·입력 포커스 중엔 양보.
* ========================================================================== */
import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const";
/** 조작 버튼 하나 — 클릭이 카드 선택·팬으로 번지지 않게 막는다. */
export function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement("button");
@@ -89,19 +91,77 @@ export function arrowsBelongToProfile(): boolean {
/** 고른 구조물의 도형 — 벽 · 집수정 · 측벽은 `culvert` 계열, BOX 끝은 판정면이 켜진다. */
const ACTIVE_SHAPES = '[class*="culvert"].is-active, .b06-chart__box-hit.is-active';
/** 끌기 손잡이가 조정 창에 묻는 것 — 걸음(m)이 null 이면 지금 고른 것은 끌 수 없다. */
/** 끈 자리를 패널 걸음 · 한계로 맞춘 결과 — 화면 이동(m · 왼쪽 + · 아래 +) · 미리 보기 글 · 반영. */
export interface DragSnap {
leftM: number;
downM: number;
text: string;
apply: () => void;
}
/** 끌기 손잡이가 조정 창에 묻는 것. */
export interface DragHandleDeps {
stepM: () => number | null;
/** 끈 거리(m · 화면 왼쪽 + · 실제 아래 +) → 맞춘 자리. null = 지금 고른 것은 끌 수 없다. */
snap: (leftM: number, downM: number) => DragSnap | null;
/** 끄는 동안 창에 적을 미리 보기(빈 글이면 지운다). */
preview: (text: string) => void;
/** 놓을 때 한 번 — 화면 왼쪽(◀) 걸음 수 · 아래(▼) 걸음 수. 둘 중 하나만 0 이 아니다. */
commit: (stepsLeft: number, stepsDown: number) => void;
}
const round1 = (value: number): number => Math.round(value * 10) / 10;
const clampTo = (value: number, low: number, high: number): number =>
Math.min(Math.max(value, low), high);
/** 십자 두 축 끌기를 맞추는 데 필요한 것 — 걸음 · ▲▼ 축 뜻 · 한계 · 반영. */
export interface AxisDragSpec {
stepM: number;
/** ▲▼ 한 칸이 1:1.2 사면(바깥 d · 아래 d/1.2)이면 사면 바깥의 화면 방향(왼쪽 = 1) · 순수 수직이면 null. */
slopeOutward: number | null;
/** 걸음 맞춘 축 이동(◀ + · ▼ +) → 한계 안 가장 가까운 축 이동. */
clamp: (left: number, down: number) => [number, number];
apply: (left: number, down: number) => void;
}
/** 1:1.2 사면 — 사면 축 한 칸 = 바깥 d · 아래 d/1.2(`_Culvert_Solve.placePipeWall` · 집수정과 같음). */
const SLOPE_RUN = FILL_SLOPE_RATIO_MIN;
/**
* 끈 거리를 십자 두 축으로 나눠 걸음에 맞춘다(108-2) — ◀▶ 축 + ▲▼ 축을 함께.
* 사면 축이면 아래 성분이 사면 칸을 정하고, 남은 가로가 ◀▶ 칸이 된다. 한계에 걸리면
* `clamp` 가 가장 가까운 허용 자리로 자르고, 미리 보기도 그 자리(놓은 뒤 결과와 같음)다.
*/
export function snapAxisDrag(leftM: number, downM: number, spec: AxisDragSpec): DragSnap {
const { stepM, slopeOutward: outward } = spec;
const snapStep = (value: number): number => round1(Math.round(value / stepM) * stepM);
const wantDown = snapStep(outward === null ? downM : downM * SLOPE_RUN);
const wantLeft = snapStep(leftM - (outward ?? 0) * wantDown);
const [left, down] = spec.clamp(wantLeft, wantDown).map(round1);
return {
leftM: left + (outward ?? 0) * down,
downM: outward === null ? down : down / SLOPE_RUN,
text: moveText(left, down),
apply: () => spec.apply(left, down),
};
}
/** 미리 보기 글 — 십자 단추 뜻 그대로(◀▶ m · ▲▼ m). */
function moveText(left: number, down: number): string {
return [
left ? `${left > 0 ? "◀" : "▶"} ${Math.abs(left).toFixed(1)}m` : "",
down ? `${down > 0 ? "▼" : "▲"} ${Math.abs(down).toFixed(1)}m` : "",
]
.filter(Boolean)
.join(" · ");
}
/** 축 한계 — 지금 값 `from` 에서 `move` 만큼 가되 [low, high] 안(조정값 공간). */
export function clampAxis(from: number, move: number, low: number, high: number): number {
return round1(clampTo(from + move, low, high) - from);
}
/**
* 그림 위 손잡이(계획서 24-4a) — 고른 구조물 도형을 누른 채 끌면 십자와 같은 걸음으로 옮긴다.
* 끄는 동안은 도형만 옮겨 보이고(미리 보기) 놓을 때 한 번 반영한다 — 반영하면 카드가 다시 그려진다.
* 좌우 · 상하 중 많이 끈 쪽 하나만 쓴다(십자와 같음). 축척은 카드 도면이 적어 둔 `data-px-per-m`.
* 끄는 동안은 도형만 맞춘 자리로 옮겨 보이고(미리 보기) 놓을 때 한 번 반영한다 — 반영하면 카드가
* 다시 그려진다. 좌우 · 상하 두 축을 함께 쓴다(108-2). 축척은 카드 도면이 적어 둔 `data-px-per-m`.
*/
export function bindDragHandle(
root: HTMLElement,
@@ -126,33 +186,22 @@ export function bindDragHandle(
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;
if (!onShape || !deps.snap(0, 0) || !(pxPerM > 0) || !ctm) return;
event.preventDefault();
event.stopPropagation();
const [startX, startY] = [event.clientX, event.clientY];
let left = 0;
let down = 0;
let snapped: DragSnap | null = null;
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;
const leftM = -(next.clientX - startX) / (pxPerM * ctm.a);
const downM = (next.clientY - startY) / (pxPerM * exaggeration * ctm.d);
snapped = deps.snap(leftM, downM);
const tx = -(snapped?.leftM ?? 0) * pxPerM;
const ty = (snapped?.downM ?? 0) * 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`
: "",
);
deps.preview(snapped?.text ?? "");
};
const up = (): void => {
window.removeEventListener("pointermove", move);
@@ -160,12 +209,12 @@ export function bindDragHandle(
window.removeEventListener("pointercancel", up);
shapes.forEach((shape) => shape.removeAttribute("transform"));
deps.preview("");
if (!left && !down) return;
if (!snapped?.text) 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);
snapped.apply();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
@@ -25,6 +25,8 @@ import {
dragReadout,
makeButton,
makeRow,
snapAxisDrag,
type AxisDragSpec,
} from "./B06_Section_UI_Cross_Panel_Base";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
@@ -66,6 +68,8 @@ export interface StructurePanelDeps {
canNudge: (key: RevetKey) => boolean;
/** 좌우·상하 걸음이 0.1m인가(독립·추가 기슭막이). 배관 기준벽은 false = 1m. */
fineMove: (key: RevetKey) => boolean;
/** 그림 끌기 두 축(108-2) — ▲▼ 축 뜻 · 한계 · 한 번 반영. 없으면 끌 수 없다. */
dragAxes?: (key: RevetKey, basin: boolean) => Omit<AxisDragSpec, "stepM"> | null;
/** 다단 기슭막이 상태 — 유출 벽 선택 시 단 수 행 표시 판단에 쓴다. */
extraState: () => { canAdd: boolean; count: number };
/** 이 카드가 연동으로 넘어온 링크 카드인가 — 단 수는 **기준(소유) 측점**에서만
@@ -456,9 +460,8 @@ export function buildStructurePanel(
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);
// 집수정 기본값은 위치 십자 가운데 ↺ 하나(108-1 — 머리줄은 제목 · 닫기만).
head.append(title, closeButton);
// 오버레이 = 읽기 + 십자(기슭막이) · 위치 십자(집수정)만 — 값 칸이 없다(24-4a). 두 조작
// 세트는 **창 맨 아래**(2026-08-24 사용자) · 동시에 뜨지 않아 같은 자리에 온다.
// 사본 = 폼 칸으로 옮겨 붙을 줄만(`_Adjust_Dock`) — 십자는 오버레이 전용(2026-08-29 지시 7).
@@ -477,22 +480,16 @@ export function buildStructurePanel(
: [basinRow.row, moveRow.row]),
);
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 같은 동작(24-4a). 오버레이만.
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 두 축 함께 · 놓을 때 한 번(108-2). 오버레이만.
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));
snap: (leftM, downM) => {
const axes = current && dragKind ? deps.dragAxes?.(current, dragKind === "basin") : null;
if (!current || !axes) return null;
const stepM = dragKind === "basin" ? BASIN_STEP_M : moveStepOf(current);
return snapAxisDrag(leftM, downM, { stepM, ...axes });
},
preview: (text) => (dragLine.textContent = text),
});
return {
@@ -540,7 +537,6 @@ export function buildStructurePanel(
.querySelector(".b06-structure-panel__btn--equal")
?.classList.toggle("is-hidden", isOwn || key !== "outlet");
basinRow.row.classList.toggle("is-hidden", !isBasin);
basinReset.classList.toggle("is-hidden", !isBasin);
// I형은 내공(높이·폭) 개념이 없다 — 내공 줄을 숨긴다.
for (const entry of basinSizeRows) {
// 화면에서 숨김(54-40 · 값 · 그림 계산은 그대로)
@@ -30,6 +30,13 @@ export function boxPanelDeps(context: BoxPanelContext): BoxPanelDeps {
}),
nudgeRise: (role, deltaM) =>
box.update(chainageM, role, { riseM: box.adjustFor(chainageM)[role].riseM + deltaM }),
move: (role, screenDeltaM, riseDeltaM) => {
const from = box.adjustFor(chainageM)[role];
box.update(chainageM, role, {
lengthM: Math.round((from.lengthM + screenDeltaM * (role === "left" ? 1 : -1)) * 10) / 10,
riseM: Math.round((from.riseM + riseDeltaM) * 10) / 10,
});
},
reset: (role) => box.reset(chainageM, role),
wingRoleFor: (role) => ((role === "left") === context.inletOnLeft ? "inlet" : "outlet"),
bodyLengthM: () => context.layout()?.bodyLengthM ?? 0,
@@ -39,6 +39,14 @@ export function fordPanelDeps(context: FordPanelContext): FordPanelDeps {
ford.update(chainage, role, {
slopeM: ford.adjustFor(chainage)[role].slopeM + deltaM,
}),
outwardOf: (role) => outwardOf(section, role),
move: (role, screenDeltaM, slopeDeltaM) => {
const from = ford.adjustFor(chainage)[role];
ford.update(chainage, role, {
lateralM: from.lateralM + screenDeltaM * outwardOf(section, role),
slopeM: from.slopeM + slopeDeltaM,
});
},
reset: (role) => ford.reset(chainage, role),
slabLengthM: context.slabLengthM,
close: context.close,
@@ -8,10 +8,13 @@
import type { CrossSection } from "./B06_Section_Api_Fetch";
import type { RevetKey } from "./B06_Section_UI_Cross_Culvert";
import {
FILL_SLOPE_RATIO_MIN,
PIPE_WALL_DEFAULT_RUN_M,
REVET_EMBED_DEPTH_M,
REVET_FORM_DEFAULT,
WALL_MIN_PURE_HEIGHT_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import { clampAxis, type AxisDragSpec } from "./B06_Section_UI_Cross_Panel_Base";
import type { CulvertLayout } from "./B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import type { StructurePanelDeps } from "./B06_Section_UI_Cross_Structure_Panel";
@@ -149,6 +152,7 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
// 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m. 세월교 흙막이는 안이 관이라 1m(80).
fineMove: (key) =>
(key !== "inlet" && key !== "outlet") || (ctx.hiddenPipe() && !ctx.fordGuard?.()),
dragAxes: (key, basin) => wallDragAxes(ctx, key, basin),
extraState: () => ctx.extraState(),
isLinkedCard: () => ctx.isLinked,
basinExtraState: () => ctx.basinExtraState(),
@@ -222,6 +226,70 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
};
}
/** 끌기 한계 ±10 m — 제어기 `clampMove`(`_Page_Station_Controls`)와 같다. */
const DRAG_LIMIT_M = 10;
const round1 = (value: number): number => Math.round(value * 10) / 10;
/**
* 벽 · 집수정 그림 끌기 두 축(108-2) — 축 뜻 · 한계 · 한 번 반영. 한계는 저장 · 기하가 거는 것 중
* 미리 알 수 있는 것(±10 m · 안쪽 금지 · 노면 위 금지 · 다단 선반 1.2·d) — 넘으면 가장 가까운 허용 자리.
* · 배관 기준벽 · 집수정 = ▲▼ 1:1.2 사면(`placePipeWall` · `_Culvert_Basin` 사면 이동)
* · 다단 · 독립 벽 = ▲▼ 순수 수직(`_Culvert_Extra` · `_Revetment` 축)
*/
function wallDragAxes(
ctx: StructurePanelContext,
key: RevetKey,
basin: boolean,
): Omit<AxisDragSpec, "stepM"> | null {
if (!ctx.revetOffset) return null;
const outward = ctx.outwardOf(basin ? "inlet" : key);
if (basin) {
const from = ctx.inletStructure?.adjustFor(ctx.section);
if (!from) return null;
const lateralMove = (left: number): number =>
clampAxis(from.lateralM, left * outward, 0, DRAG_LIMIT_M);
return {
slopeOutward: outward,
clamp: (left, down) => [
lateralMove(left) * outward,
clampAxis(from.slopeM, down, 0, DRAG_LIMIT_M),
],
apply: (left, down) =>
ctx.inletStructure?.updateAdjust(ctx.adjustChainage(), {
...from,
lateralM: round1(from.lateralM + left * outward),
slopeM: round1(from.slopeM + down),
}),
};
}
const adjust = ctx.adjustOf(key);
const d0 = adjust.d ?? ctx.appliedD().get(key) ?? 0;
const diagonal = key === "inlet" || key === "outlet";
// x 하한 — 배관 기준벽 = 안쪽 금지(흙막이는 본체 끝이 0점이라 안 막음 · 73-4) · 다단 = 선반 ≥ 0 ·
// 독립 = 성토선 각도(1.2·d − 기본 run)
const xLow = (d: number): number =>
isTierKey(key)
? Math.max(0, FILL_SLOPE_RATIO_MIN * d)
: key === "own"
? FILL_SLOPE_RATIO_MIN * d - PIPE_WALL_DEFAULT_RUN_M
: ctx.bodyGuard?.()
? -DRAG_LIMIT_M
: 0;
return {
slopeOutward: diagonal ? outward : null,
clamp: (left, down) => {
const dMove = clampAxis(d0, down, 0, DRAG_LIMIT_M);
const low = Math.max(xLow(d0 + dMove), -DRAG_LIMIT_M);
return [clampAxis(adjust.x, left * outward, low, DRAG_LIMIT_M) * outward, dMove];
},
apply: (left, down) =>
ctx.revetOffset?.update(ctx.adjustChainage(), key, {
x: round1(adjust.x + left * outward),
d: round1(d0 + down),
}),
};
}
/**
* 조정창이 쓸 **카드 상태**를 마지막 기하 결과에서 뽑는다(2026-08-25 분리 — 카드
* 렌더러 700줄). 값 계산은 하지 않고 레이아웃 결과를 조정창 키 체계로 옮기기만 한다.
@@ -174,8 +174,9 @@
grid-area: se;
}
.b06-structure-panel__basin-reset {
margin-left: auto;
/* 횡단도 안 조정창 = 제목 · 단추만(108-1) — 줄 이름은 좌측 사본에만. */
.b06-structure-panel:not(.b06-structure-panel--dock) .b06-structure-panel__label {
display: none;
}
.b06-structure-panel__select {
@@ -297,11 +298,6 @@
display: none;
}
/* 집수정 기본값(↺)은 9키와 한 세트라 오버레이에만 둔다. */
.b06-structure-panel--dock .b06-structure-panel__basin-reset {
display: none;
}
/* 좌측 폼과 중복된 행은 사본에서 숨긴다(2026-08-29 사용자 지시 3·5):
* 형태 = 유입구·유출구 그룹의 "형태", 구조물 형식 = 유입구 "구조"로 합쳤다. */
.b06-structure-panel--dock .b06-structure-panel__struct--form,
@@ -0,0 +1,302 @@
/* PLAN 108 단위검증 헬퍼 — 조정창 끌기 두 축 · 걸음 맞춤 · 미리 보기 = 결과 · BOX 끝 판정면.
* TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 결과는 표준출력에 JSON 한 벌.
* 끌기 손잡이(`bindDragHandle`)는 가로채서 창이 넘긴 `snap` 을 그대로 부른다.
*
* ⚠ 확장자가 `.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();
/** 가로챌 모듈 — 아래에서 채운다(창 모듈을 부르기 전에). */
let STUBS = {};
// ── 화면 그릇 흉내 — 조정창이 쓰는 만큼만 ─────────────────────────────────────
class FakeNode {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = [];
this.dataset = {};
this._text = "";
this._classes = new Set();
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);
}
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() {}
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();
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);
return box.exports;
}
const B06 = (name) => path.join(ROOT, "B06_Section", `${name}.ts`);
const base = loadTs(B06("B06_Section_UI_Cross_Panel_Base"));
/** 창이 넘긴 끌기 deps — 마지막 것이 지금 창. */
const drags = [];
STUBS = {
"./B06_Section_UI_Section_Common": { L: (key) => key },
"@ui/ui_template_elements": { showToast: () => {} },
"./B06_Section_UI_Cross_Panel_Base": {
...base,
bindDragHandle: (root, deps) => {
drags.push(deps);
return { attach: () => {}, detach: () => {} };
},
},
};
const { buildStructurePanel } = loadTs(B06("B06_Section_UI_Cross_Structure_Panel"));
const { buildBoxPanel } = loadTs(B06("B06_Section_UI_Cross_Box_Panel"));
const { buildFordPanel } = loadTs(B06("B06_Section_UI_Cross_Ford_Panel"));
const { structurePanelDeps } = loadTs(B06("B06_Section_UI_Cross_View_Structure"));
const { placePipeWall } = loadTs(B06("B06_Section_UI_Cross_Culvert_Solve"));
const { boxHitBands } = loadTs(B06("B06_Section_UI_Cross_Box"));
const r3 = (v) => Math.round(v * 1000) / 1000;
const snapOut = (s) => (s ? { leftM: r3(s.leftM), downM: r3(s.downM), text: s.text } : null);
// ── 벽 · 집수정 창 — 실제 배선(structurePanelDeps)으로 ─────────────────────────
function wallCase({ key, structure = "revet", outward, adjust, appliedD = 0, basin, guard }) {
const updates = [];
const ctx = {
section: { chainage_m: 100 },
adjustChainage: () => 100,
isLinked: false,
revetOffset: { update: (at, role, patch) => updates.push({ role, ...patch }) },
inletStructure: {
valueFor: () => structure,
adjustFor: () => basin ?? { innerWidthM: 1, innerHeightM: 1.2, lateralM: 0, slopeM: 0 },
updateAdjust: (at, next) => updates.push({ basin: next }),
},
adjustOf: () => adjust,
outwardOf: () => outward,
heightOfWall: () => 2,
drawnWallKeys: () => [key],
formOfWall: () => "돌쌓기(메)",
appliedD: () => new Map([[key, appliedD]]),
inletIsBasin: () => structure !== "revet",
hiddenPipe: () => false,
bodyGuard: () => !!guard,
fordGuard: () => false,
inletOptions: () => ({ revetAllowed: true, basinLUAllowed: true }),
extraState: () => ({ canAdd: true, count: 1 }),
basinExtraState: () => ({ canAdd: false, count: 0 }),
activeRevet: () => key,
setActiveRevet: () => {},
toggleRevet: () => {},
scrollKey: "t",
};
const panel = buildStructurePanel(structurePanelDeps(ctx));
panel.show(key);
const drag = drags[drags.length - 1];
return {
panel,
snap: (leftM, downM) => {
const s = drag.snap(leftM, downM);
if (s?.text) s.apply();
return { snap: snapOut(s), update: updates.pop() ?? null };
},
};
}
/** 배관 기준벽 자리(`placePipeWall`) — 미리 보기 = 결과 판정용. 화면 왼쪽 = offset +. */
const wallAt = (outward, adjust) =>
placePipeWall({
autoOffset: 5 * outward,
outward,
height: 2,
baseElevation0: 98,
edgeElevation: 100,
invertCap: 99,
adjust: { x: adjust.x, d: adjust.d, h: null, m: null },
defaultD: 0,
});
const shiftOf = (outward, from, to) => {
const a = wallAt(outward, from);
const b = wallAt(outward, to);
return { leftM: r3(b.anchorOffset - a.anchorOffset), downM: r3(a.topElevation - b.topElevation) };
};
const out = { wall: {}, box: {}, ford: {} };
for (const outward of [1, -1]) {
const from = { x: 0, d: 2, h: null, m: null };
const { snap, panel } = wallCase({ key: "outlet", outward, adjust: from });
// 대각 끌기 — 화면 왼쪽 3.3 · 아래 1.7 (outward = 1 기준), 반대편은 화면 좌우만 뒤집음
const got = snap(3.3 * outward, 1.7);
out.wall[`diag${outward}`] = {
...got,
result: got.update ? shiftOf(outward, from, got.update) : null,
overlayHead: panel.root.children[0].children.map((c) => c.className),
};
}
// 한계 — 사면 위로 넘김 · 안쪽으로 넘김 → 가장 가까운 허용 자리(d 0 · x 0)
out.wall.limit = wallCase({
key: "outlet",
outward: 1,
adjust: { x: 0.5, d: 0.5, h: null, m: null },
}).snap(-5, -3);
// 흙막이(세월교 · BOX) — 본체 끝이 0점이라 안쪽(음수 x)도 됨 · ±10 m 끝
out.wall.guard = wallCase({
key: "outlet",
outward: 1,
adjust: { x: 0, d: 0, h: null, m: null },
guard: true,
}).snap(-14, 0);
// 다단 벽 — ▲▼ 순수 수직 · 0.1 걸음 · 선반 x ≥ 1.2·d 가 따라 나감
out.wall.extra = wallCase({
key: "extra0",
outward: 1,
adjust: { x: 0, d: 0, h: null, m: null },
}).snap(0.04, 1.0);
// 집수정 — 1:1.2 사면 · 0.1 걸음 · 좌우 · 사면 ≥ 0
out.wall.basin = wallCase({
key: "inlet",
structure: "L",
outward: -1,
adjust: { x: 0, d: null, h: null, m: null },
basin: { innerWidthM: 1, innerHeightM: 1.2, lateralM: 0.3, slopeM: 0 },
}).snap(-0.86, 0.25);
out.wall.basinNone = wallCase({
key: "inlet",
structure: "L",
outward: -1,
adjust: { x: 0, d: null, h: null, m: null },
}).snap(0.02, -0.01);
// ── BOX 창 — 길이(화면 좌 +) · 표고 ───────────────────────────────────────────
function boxCase(role, adjust, leftM, downM) {
const moves = [];
const panel = buildBoxPanel({
adjustFor: () => adjust,
nudgeLength: () => {},
nudgeRise: () => {},
move: (r, screen, rise) => moves.push({ role: r, screen: r3(screen), rise: r3(rise) }),
reset: () => {},
wingRoleFor: () => "inlet",
bodyLengthM: () => 5,
slopeRatio: () => null,
close: () => {},
});
panel.show(role);
const s = drags[drags.length - 1].snap(leftM, downM);
if (s?.text) s.apply();
return {
snap: snapOut(s),
move: moves[0] ?? null,
value: panel.root.children[1].children.map((c) => c.className || "line"),
};
}
out.box.left = boxCase("left", { lengthM: 0, riseM: 0 }, 0.47, 0.33);
out.box.right = boxCase("right", { lengthM: 1, riseM: 0 }, -0.47, -0.21);
out.box.shorten = boxCase("left", { lengthM: 0.2, riseM: 9.9 }, -0.9, -0.5);
// ── 세월교 창 — 좌우 + 1:1.2 사면 · 0.1 걸음 ─────────────────────────────────────
function fordCase(outward, adjust, leftM, downM) {
const moves = [];
const panel = buildFordPanel({
adjustFor: () => adjust,
heightFor: () => 1.5,
nudge: () => {},
nudgeSlope: () => {},
outwardOf: () => outward,
move: (r, screen, slope) => moves.push({ role: r, screen: r3(screen), slope: r3(slope) }),
reset: () => {},
slabLengthM: () => 3.2,
close: () => {},
});
panel.show("inlet");
const s = drags[drags.length - 1].snap(leftM, downM);
if (s?.text) s.apply();
return {
snap: snapOut(s),
move: moves[0] ?? null,
value: panel.root.children[1].children.map((c) => c.className || "line"),
};
}
out.ford = {
diag: fordCase(1, { heightM: null, lateralM: 0, slopeM: 0 }, 0.53, 0.27),
inward: fordCase(-1, { heightM: null, lateralM: 0.2, slopeM: 0 }, 0.9, 0),
};
// ── BOX 판정면 — 좌측 끝 날개벽(에이프런 4 → 6) · 우측 끝 날개벽 없음 ───────────
const layout = {
sides: [
{ role: "left", offset: 4, apron: [{ offset: 4 }, { offset: 6 }] },
{ role: "right", offset: -4, apron: [] },
],
topSlab: [{ elevation: 99 }, { elevation: 99 }],
bottomSlab: [{ elevation: 96.5 }, { elevation: 96.5 }],
};
out.hit = Object.fromEntries(
boxHitBands(layout).map(([role, points]) => [
role,
{
offsets: [...new Set(points.map((p) => r3(p.offset)))].sort((a, b) => a - b),
elevations: [...new Set(points.map((p) => p.elevation))].sort((a, b) => a - b),
},
]),
);
process.stdout.write(JSON.stringify(out));
+124
View File
@@ -0,0 +1,124 @@
"""PLAN 108 — 횡단도 안 조정창 끌기 두 축 · 패널 걸음 맞춤 · 미리 보기 = 결과 · BOX 끝 판정면.
짝: `B06_Section/B06_Section_UI_Cross_Panel_Base.ts`(`snapAxisDrag` · `bindDragHandle`) ·
`_Cross_Structure_Panel.ts` · `_Cross_View_Structure.ts`(벽 · 집수정 축 · 한계) · `_Cross_Box_Panel.ts` ·
`_Cross_Box.ts`(`boxHitBands`). 실제 TS 를 `helper_108_drag_box_hit.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(__file__)
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_108_drag_box_hit.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@pytest.mark.parametrize("side", ["diag1", "diag-1"])
def test_대각_끌기는_두_축이_함께_걸음_배수로(out, side) -> None:
"""배관 기준벽(1 m 걸음) — 사면 d 두 칸 + 수평 x 한 칸이 한 번에 · 값은 걸음 배수."""
case = out["wall"][side]
assert case["update"] == {"role": "outlet", "x": 1, "d": 4}
assert "◀" in case["snap"]["text"] or "▶" in case["snap"]["text"]
assert "▼ 2.0m" in case["snap"]["text"]
@pytest.mark.parametrize("side", ["diag1", "diag-1"])
def test_미리보기_자리는_놓은_뒤_자리와_같다(out, side) -> None:
"""미리 보기 이동 = `placePipeWall` 로 다시 푼 벽 자리 이동(화면 왼쪽 · 아래)."""
case = out["wall"][side]
assert case["snap"]["leftM"] == case["result"]["leftM"]
assert case["snap"]["downM"] == case["result"]["downM"]
def test_한계면_가장_가까운_허용_자리(out) -> None:
"""사면 위로 · 안쪽으로 넘겨 끌면 d 0 · x 0 에서 멈춤 — 원래 자리로 돌아가지 않음."""
case = out["wall"]["limit"]
assert case["update"] == {"role": "outlet", "x": 0, "d": 0}
assert case["snap"] == {"leftM": -1, "downM": -0.417, "text": "▶ 0.5m · ▲ 0.5m"}
def test_흙막이는_안쪽도_되고_10m_에서_멈춘다(out) -> None:
assert out["wall"]["guard"]["update"] == {"role": "outlet", "x": -10, "d": 0}
assert out["wall"]["guard"]["snap"]["leftM"] == -10
def test_다단_벽은_순수_수직_선반이_따라_나감(out) -> None:
"""다단 ▲▼ = 수직 하강 · x ≥ 1.2·d(선반) — 미리 보기도 그 자리."""
case = out["wall"]["extra"]
assert case["update"] == {"role": "extra0", "x": 1.2, "d": 1}
assert case["snap"]["leftM"] == 1.2 and case["snap"]["downM"] == 1
def test_집수정은_사면_축_0_1_걸음(out) -> None:
case = out["wall"]["basin"]
assert case["update"]["basin"]["lateralM"] == 0.9
assert case["update"]["basin"]["slopeM"] == 0.3
assert case["snap"] == {"leftM": -0.9, "downM": 0.25, "text": "▶ 0.6m · ▼ 0.3m"}
def test_걸음_반도_안_끌면_반영_없음(out) -> None:
assert out["wall"]["basinNone"] == {
"snap": {"leftM": 0, "downM": 0, "text": ""},
"update": None,
}
def test_BOX_끌기는_길이_표고_함께(out) -> None:
assert out["box"]["left"]["move"] == {"role": "left", "screen": 0.5, "rise": -0.3}
assert out["box"]["left"]["snap"]["leftM"] == 0.5
assert out["box"]["right"]["move"] == {"role": "right", "screen": -0.5, "rise": 0.2}
def test_BOX_한계_길이_0_표고_10(out) -> None:
"""안으로 줄이면 길이 0 에서 · 표고는 +10 m 에서 멈춤."""
assert out["box"]["shorten"]["move"] == {"role": "left", "screen": -0.2, "rise": 0.1}
def test_세월교_끌기는_좌우_사면_함께(out) -> None:
"""사면 d 0.3 · 좌우 0.2(0.1 걸음) — 미리 보기 = 좌 0.2 + 0.3 · 아래 0.3/1.2."""
case = out["ford"]["diag"]
assert case["move"] == {"role": "inlet", "screen": 0.2, "slope": 0.3}
assert case["snap"] == {"leftM": 0.5, "downM": 0.25, "text": "◀ 0.2m · ▼ 0.3m"}
def test_세월교_노견_안쪽은_0_에서_멈춤(out) -> None:
"""좌우 0.2 에서 안쪽으로 0.9 끌어도 0 까지만(가장 가까운 허용 자리)."""
case = out["ford"]["inward"]
assert case["move"] == {"role": "inlet", "screen": 0.2, "slope": 0}
assert case["snap"]["leftM"] == 0.2
def test_조정창은_제목과_단추만(out) -> None:
"""머리줄 = 제목 · 닫기(집수정 ↺ 없음) · BOX 오버레이 읽기 줄 = 끄는 중 미리 보기 하나."""
assert out["wall"]["diag1"]["overlayHead"] == [
"b06-structure-panel__title",
"b06-structure-panel__btn b06-structure-panel__close",
]
assert out["box"]["left"]["value"] == ["b06-structure-panel__drag"]
assert out["ford"]["diag"]["value"] == ["b06-structure-panel__drag"]
def test_BOX_끝_판정면은_본체로_안_넘어감(out) -> None:
"""본체 = 두 구체 끝 사이 · 끝 = 구체 끝 ~ 에이프런 바깥 · 날개벽 없는 끝 = 바깥 0.3 m 띠."""
hit = out["hit"]
assert hit["body"]["offsets"] == [-4, 4]
assert hit["left"]["offsets"] == [4, 6]
assert hit["right"]["offsets"] == [-4.3, -4]
for role in ("body", "left", "right"):
assert hit[role]["elevations"] == [96.5, 99]