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:
@@ -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"),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user