diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 6d10d713..f5c78ab3 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -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), }); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 2e38ce36..950395fc 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -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); } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index a56de443..f6617b4d 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -439,6 +439,61 @@ export function createRevetmentFields( }; } +/** 집수정 기준측점 전 · 후 한 줄 — 옛 조정창에만 있던 칸(계획서 24-4a). 길이 칸과 짝: + * 전 + 후 = 길이 · 나중에 고친 쪽이 살고 반대쪽이 나머지(기슭막이 칸과 같은 규칙). */ +export function createBasinSplitFields(length: HTMLInputElement): { + row: HTMLDivElement; + write: (options: Record) => void; + read: (target: Record) => 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 사용자 확정). 그 밖의 규격은 diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Hidden.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Hidden.ts index 9cfc15fa..5567822b 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Hidden.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Hidden.ts @@ -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 }; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts index 1d55eee9..56e623b2 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -35,6 +35,8 @@ export const FORM_OPTION_KEYS: Readonly> = { "inlet_basin_form", "inlet_basin_material", "inlet_basin_length_m", + "inlet_basin_before_m", + "inlet_basin_after_m", ...revetKeys("inlet"), "outlet_type", ...revetKeys("outlet"), diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index a3ba760e..9df78bb8 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -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"); diff --git a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts index 52a2a1f9..4aa0acff 100644 --- a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts @@ -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(); }, }; diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts index 0b88e91f..dda551ef 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts @@ -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) => 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, diff --git a/B06_Section/B06_Section_UI_Cross_Panel_Base.ts b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts index 8df6afbc..4e01b65f 100644 --- a/B06_Section/B06_Section_UI_Cross_Panel_Base.ts +++ b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts @@ -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("svg.b06-section__chart"); + const layer = svg?.querySelector(".b06-chart__plot-layer"); + if (!svg || !layer || !svg.contains(event.target as Node)) return; + const shapes = [...svg.querySelectorAll(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 = { ArrowUp: "up", ArrowDown: "down", diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 5cea01b1..c9becd98 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -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) => 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) => () => 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(); diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 726b7417..d362bc1c 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -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")}`, }); diff --git a/B06_Section/B06_Section_UI_Cross_View_Ford.ts b/B06_Section/B06_Section_UI_Cross_View_Ford.ts index ee4a415c..30159b34 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Ford.ts @@ -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, }; diff --git a/B06_Section/B06_Section_UI_Cross_View_Structure.ts b/B06_Section/B06_Section_UI_Cross_View_Structure.ts index bdfbe4af..7e25d81a 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Structure.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Structure.ts @@ -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(), diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts index 32312a4e..610dd8dd 100644 --- a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts +++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts @@ -62,6 +62,11 @@ export function applyPipeOptionsToCache( const put = (target: T, field: keyof T, value: unknown): void => { if (value !== undefined) (target as Record)[field as string] = value; }; + /** 폼 칸 값 — 폼이 보낸 것이 칸 전부라 비운 칸은 지운다(계획서 24-4a 빈 값). */ + const own = (target: T, field: keyof T, value: unknown): void => { + if (value === undefined) delete (target as Record)[field as string]; + else (target as Record)[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, diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 0feeb91f..bf452c50 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -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) => void; /** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 — * 조정창과 같은 흐름이라 폼 조작도 도면에 바로 보인다(2026-08-29 사용자 보고). */ applyPipeOptions?: (chainageM: number, patch: Record) => 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; + if (!deps.projectId) { showToast(PIPE_ADD_GUIDE, "error"); return; } - // 단 수는 옵션이자 **조작 채널**이다 — 조정창에서 세우던 그 자리로 보내야 - // 횡단도에 단이 선다(2026-08-30 사용자 지시 3: 배수관 측점과 동일하게). - // 옵션은 **옮기기 전 자리**로 예약한다 — 저장 시점의 관 목록이 그 자리 기준이다. - deps.queuePipeOptions(fromChainageM, patch as Record); - deps.applyPipeOptions?.(fromChainageM, patch as Record); - // 화면 캐시(목록·폼 재로드용)도 같이 맞춘다 — 저장 전에도 값이 유지된다. + // 값은 관 목록 초안(가게 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); }, diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css index d5f9bdab..f740f9e0 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -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"; diff --git a/resources/tester/helper_b06_adjust_single_form.cjs b/resources/tester/helper_b06_adjust_single_form.cjs new file mode 100644 index 00000000..294862ae --- /dev/null +++ b/resources/tester/helper_b06_adjust_single_form.cjs @@ -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)); diff --git a/resources/tester/test_b05_facility_options_merge.py b/resources/tester/test_b05_facility_options_merge.py index 9b4a4593..6b888072 100644 --- a/resources/tester/test_b05_facility_options_merge.py +++ b/resources/tester/test_b05_facility_options_merge.py @@ -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"} diff --git a/resources/tester/test_b06_adjust_single_form.py b/resources/tester/test_b06_adjust_single_form.py new file mode 100644 index 00000000..ab647100 --- /dev/null +++ b/resources/tester/test_b06_adjust_single_form.py @@ -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