wip(B05·B06): 54-27 · 54-28 유출 「추가 흙막이(단)」 칸 · 자동 자리 글 삭제 — 중지(사용자) · 남은 일 횡단 흙막이 옮김 → 좌측 관 길이 화면 확인
- 관 보호공(유출) 상자 안 「추가 흙막이(단)」 칸(- 숫자 + · 0~9 · 지형이 세운 만큼) — 횡단 조정 단 수와 같은 자리 - 종단 보기에서 바꾸면 횡단 보기를 숨긴 채 세워 같은 단 수 제어로 씀 - 늘어난 흙막이 상자 구조 칸 「흙막이 (기본)」 - 배관 유출 칸은 옛 단 수 줄을 안 받음(중복 방지) - 관 길이 · 좌우 · 상하(사면) 자동 자리 읽기 글과 문구 키 삭제 - 벽 조작값이 바뀌면 좌측 관 길이 다시 셈(120ms 모음) - 시험 test_54_27_soil_tier_field(단 칸 · N → 상자 N · 램프 자리 · 자동 자리 글 없음) 화면 확인(B05 12+18): 단 0→1→2→1 에 상자 0→1→2→1 · 램프 3→4→5→4 · 자동 자리 글 0 · B06 카드 다단 벽 2 남은 일: 횡단 흙막이 옮김 → 좌측 관 길이 바뀜 화면 확인 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
This commit is contained in:
@@ -59,6 +59,8 @@ export interface SpecFormHooks {
|
||||
* [추가] · [빼기] 없음 · 줄면 끝에서부터 가림(값은 남음 · 54-10) · null = 모름(저장된 것 그대로) ·
|
||||
* undefined = 손 [추가]. */
|
||||
count?: (c: SpecContainer) => number | null | undefined;
|
||||
/** 컨테이너 칸 끝에 덧붙이는 칸(54-27 배관 유출 「추가 흙막이(단)」 · 값은 저장 옵션 밖). */
|
||||
fields?: (c: SpecContainer, values: Record<string, string>) => HTMLElement[];
|
||||
/** 그림이 쓰는 값(54-13) — 고르기 칸이 비었거나 자동 규칙일 때 그 값 · 꼬리(「기본」 · 「자동」) ·
|
||||
* null = 없음. 보여 주기만(값 · 저장은 사용자가 고를 때만). `values` = 보이는 값(위 칸 반영). */
|
||||
shown?: (
|
||||
@@ -222,7 +224,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
||||
);
|
||||
return wrap;
|
||||
});
|
||||
box.body.append(grid(...fields));
|
||||
box.body.append(grid(...fields, ...(hooks.fields?.(c, values) ?? [])));
|
||||
if (c.required) {
|
||||
const slot = slotOf(c.id);
|
||||
// B06 조정 강조(테두리 · 이름표)는 칸 상자에 붙음 — 다시 그려도 이어 감(53-1)
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* 셈 · 시험 test_b06_pipe_length) 그대로 — 여기서 새로 셈하지 않음 · 화면에 보이기만(저장 안 함).
|
||||
* 높이 = 손 입력 · 횡단도 조정 높이는 B06 `withSpecDefaults` 가 같은 키(`*_revet_height_m`)에 실음 ·
|
||||
* 빈 칸 안내 = 관경 기준 최소 높이(`pipeWallMinHeightM` ↔ 파이썬 `pipe_wall_min_height_m` 짝).
|
||||
* 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」(횡단 설계 patch `extra_wall_counts` · 54-1) 중 지형이 세운 단(54-10) — 읽기만.
|
||||
* 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」(횡단 설계 patch `extra_wall_counts` · 54-1) 중 지형이 세운 단(54-10) ·
|
||||
* 쓰기는 횡단 보기 단 수 제어로만(`setPipeExtraCount` · 54-27).
|
||||
* ========================================================================== */
|
||||
|
||||
import { pipeWallMinHeightM } from "../../common_util/common_util_culvert_sets";
|
||||
@@ -25,6 +26,23 @@ export function setPipeExtraCountSource(source: (section: CrossSection) => numbe
|
||||
extraCountSource = source;
|
||||
}
|
||||
|
||||
/** 유출 단 수를 쓰는 곳 — 횡단 보기의 단 수 제어(54-27) · 종단 보기는 횡단 보기를 숨긴 채 세워 씀. */
|
||||
let extraCountSink: ((section: CrossSection, count: number) => void) | null = null;
|
||||
let crossOpener: (() => Promise<void>) | null = null;
|
||||
export function setPipeExtraCountSink(sink: (section: CrossSection, count: number) => void): void {
|
||||
extraCountSink = sink;
|
||||
}
|
||||
export function setPipeCrossOpener(opener: () => Promise<void>): void {
|
||||
crossOpener = opener;
|
||||
}
|
||||
|
||||
/** 좌측 「추가 흙막이(단)」 → 주인 측점 유출 단 수(B06 횡단 조정과 같은 저장 자리 · 54-27). */
|
||||
export async function setPipeExtraCount(chainageM: number | null, count: number): Promise<void> {
|
||||
if (!extraCountSink) await crossOpener?.();
|
||||
const found = ownerSection(chainageM);
|
||||
if (found) extraCountSink?.(found.owner, count);
|
||||
}
|
||||
|
||||
/** 유입 형식을 대는 곳 — 횡단 보기의 세션 값 우선(`valueFor`) · 없으면 정본 patch(54-13). */
|
||||
type InletChoice = NonNullable<CrossSection["design"]>["inlet_structure"];
|
||||
let inletSource: ((section: CrossSection) => InletChoice) | null = null;
|
||||
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
import type { DesignPageHost } from "./design_page";
|
||||
import {
|
||||
pipeExtraCount,
|
||||
setPipeExtraCountSink,
|
||||
setPipeExtraCountSource,
|
||||
setPipeInletSource,
|
||||
setPipeSectionsSource,
|
||||
@@ -132,6 +133,7 @@ export async function mountCrossView(
|
||||
((structures: StructureInstance[], types: StructureType[]) => void) | null = null;
|
||||
/** 관 목록 초안을 고치면 한 벌 배수유역도가 그 초안으로 다시 선다(잇단 조작은 한 번). */
|
||||
let pipeReloadTimer = 0;
|
||||
let wallAdjustTimer = 0;
|
||||
const structuresPanel = createB06StructuresPanel({
|
||||
projectId,
|
||||
shared,
|
||||
@@ -294,9 +296,21 @@ export async function mountCrossView(
|
||||
projectId: () => projectId,
|
||||
onSaveError: (message) => showToast(`배수관 구간값 저장 실패 — ${message}`, "error"),
|
||||
onExtraCount: () => structuresPanel.facility.recount(),
|
||||
// 흙막이 자리를 옮기면 관 길이가 바뀜 — 좌측 「관 길이 (m)」 다시(54-28) · 끌기 · 되받기는 한 번으로 모음
|
||||
onWallAdjust: () => {
|
||||
window.clearTimeout(wallAdjustTimer);
|
||||
wallAdjustTimer = window.setTimeout(() => structuresPanel.facility.recount(), 120);
|
||||
},
|
||||
});
|
||||
// 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선
|
||||
setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet"));
|
||||
// 좌측 「추가 흙막이(단)」도 같은 단 수 제어로(54-27) — 사라지는 단을 고른 중이면 유출 벽으로 옮김
|
||||
setPipeExtraCountSink((owner, count) => {
|
||||
const picked = stationControls.revetOffset.highlightFor(owner) ?? "";
|
||||
if (/^extra\d+$/.test(picked) && Number(picked.slice(5)) >= count)
|
||||
stationControls.revetOffset.select(owner.chainage_m, "outlet");
|
||||
stationControls.extraWalls.setCount(owner.chainage_m, count, "outlet");
|
||||
});
|
||||
// 폼이 보이는 유입 그림 값(54-13) — 세션 형식 우선
|
||||
setPipeInletSource((section) => stationControls.inletStructure.valueFor(section));
|
||||
|
||||
@@ -527,6 +541,7 @@ export async function mountCrossView(
|
||||
});
|
||||
host.onDispose(() => {
|
||||
window.clearTimeout(pipeReloadTimer);
|
||||
window.clearTimeout(wallAdjustTimer);
|
||||
sectionView.dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ import {
|
||||
} from "../../B05_Profile/B05_Profile_UI_Page_Actions";
|
||||
import { createLatestSource, panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
|
||||
import { stationLinesOf } from "../../B05_Profile/B05_Profile_UI_Page_Stations";
|
||||
import { setPipeSectionsSource } from "./design_structure_pipe_calc";
|
||||
import { setPipeCrossOpener, setPipeSectionsSource } from "./design_structure_pipe_calc";
|
||||
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
|
||||
import "../../B05_Profile/B05_Profile_UI_Style.css";
|
||||
// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04).
|
||||
@@ -196,6 +196,8 @@ export async function mountProfileView(
|
||||
let currentSectionDetail: SectionDetailResponse | null = null;
|
||||
// 배관 관 길이 · 흙막이 개수(54-1) — 종단 보기도 같은 횡단 자료로(B05 = B06)
|
||||
setPipeSectionsSource(() => currentSectionDetail?.cross_sections ?? null);
|
||||
// 좌측 단 수를 바꾸면 횡단 보기를 숨긴 채 세워 같은 단 수 제어로 씀(54-27)
|
||||
setPipeCrossOpener(() => shared.ensureView("cross"));
|
||||
/** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */
|
||||
let corridorVisible = true;
|
||||
let routeReady = false;
|
||||
|
||||
@@ -40,8 +40,10 @@ import {
|
||||
INLET_REVET_KEYS,
|
||||
type InletStructureKind,
|
||||
labeled,
|
||||
numberInput,
|
||||
optionalSelect,
|
||||
OUTLET_REVET_KEYS,
|
||||
stepper,
|
||||
WING_IN_KEYS,
|
||||
WING_OUT_KEYS,
|
||||
} from "./B05_Profile_UI_Drainage_Facility_Fields";
|
||||
@@ -61,6 +63,7 @@ import {
|
||||
pipeDrawn,
|
||||
pipeExtraCount,
|
||||
pipeLengthM,
|
||||
setPipeExtraCount,
|
||||
} from "../A00_Common/design/design_structure_pipe_calc";
|
||||
import {
|
||||
hasLinks,
|
||||
@@ -298,7 +301,28 @@ export function createFacilityOptionsForm(
|
||||
}
|
||||
return drawnMemo;
|
||||
};
|
||||
// 유출 「추가 흙막이(단)」(54-27) — 값 = 횡단 조정 단 수(같은 저장 자리) · 흙막이 상자 수가 따라감
|
||||
const tiersInput = numberInput("1", "0", "횡단 설계 뒤");
|
||||
tiersInput.max = "9";
|
||||
const tiersRow = labeled("추가 흙막이(단)", stepper(tiersInput, 1, 0));
|
||||
tiersRow.dataset.specItem = "outlet.tiers";
|
||||
tiersRow
|
||||
.querySelectorAll("button")
|
||||
.forEach((b, i) => (b.title = `1단 ${i ? "늘리기" : "줄이기"}`));
|
||||
const paintTiers = (values: Record<string, string>): HTMLElement => {
|
||||
const count = pipeExtraCount(chainage);
|
||||
tiersInput.value = count === null ? "" : String(count);
|
||||
tiersInput.disabled = count === null;
|
||||
tiersRow.hidden = !!values.structure && values.structure !== "기슭막이";
|
||||
return tiersRow;
|
||||
};
|
||||
tiersInput.addEventListener("change", () => {
|
||||
const count = Math.max(0, Math.min(9, Math.round(Number(tiersInput.value) || 0)));
|
||||
// 지형이 못 세운 단은 횡단 기하가 자름 — 끝난 뒤 세운 만큼으로 다시 그림
|
||||
void setPipeExtraCount(chainage, count).then(() => pipeForm.recount());
|
||||
});
|
||||
const pipeForm = createSpecForm(specLinks, {
|
||||
fields: (c, values) => (c.id === "outlet" ? [paintTiers(values)] : []),
|
||||
shown: (c, item, values, index) => {
|
||||
if (c.id === "inlet" && item.id === "structure" && (!values.structure || inletIsAuto())) {
|
||||
const drawn = drawnNow();
|
||||
@@ -307,6 +331,8 @@ export function createFacilityOptionsForm(
|
||||
: { value: "", tag: "자동(지형 규칙)" };
|
||||
}
|
||||
if (values[item.id]) return null;
|
||||
// 흙막이 상자 구조 = 표 01 흙막이 하나(54-27)
|
||||
if (c.id === "soil_add" && item.id === "structure") return { value: "기슭막이", tag: "기본" };
|
||||
if (item.id !== "form") return byDefault(branchOf(item, values)?.key);
|
||||
const basin = c.id === "inlet" && values.structure === "집수정";
|
||||
const drawn = drawnNow();
|
||||
|
||||
@@ -57,6 +57,12 @@ const SIDE_ROWS = [
|
||||
".b06-structure-panel__struct--wall-height",
|
||||
].join(", ");
|
||||
|
||||
/** 배관 유출 칸 — 읽기 줄(54-1) · 단 수 줄(폼 「추가 흙막이(단)」 칸이 맡음 · 54-27)은 안 받음. */
|
||||
const PIPE_OUTLET_ROWS = SIDE_ROWS.replace(".b06-structure-panel__struct--readout, ", "").replace(
|
||||
".b06-structure-panel__struct--tier, ",
|
||||
"",
|
||||
);
|
||||
|
||||
/** 이식 자리가 지금 화면에 서 있는지 — 그 위 어느 줄이든 숨었으면(다른 시설 명세 폼 등) 옮기지 않는다. */
|
||||
function slotUsable(target: HTMLElement | undefined): target is HTMLElement {
|
||||
return !!target?.closest("fieldset") && !target.closest("[hidden]");
|
||||
@@ -156,11 +162,7 @@ function relocateSideRows(panelRoot: HTMLElement): void {
|
||||
]
|
||||
: undefined;
|
||||
if (soil) {
|
||||
moveRows(
|
||||
panelRoot,
|
||||
SIDE_ROWS.replace(".b06-structure-panel__struct--readout, ", ""),
|
||||
ownerSlot,
|
||||
);
|
||||
moveRows(panelRoot, PIPE_OUTLET_ROWS, ownerSlot);
|
||||
setGroupLabel(soil, "");
|
||||
soil.classList.add("is-active");
|
||||
soil.scrollIntoView({ block: "nearest" });
|
||||
@@ -172,14 +174,12 @@ function relocateSideRows(panelRoot: HTMLElement): void {
|
||||
moveRows(panelRoot, EXTRA_ROWS, target);
|
||||
// 단 수·옵션은 그 단이 딸린 부모 벽의 값이라 부모 칸에 그대로 세운다 — 다단을
|
||||
// 고를 때 그 두 행이 사라지던 것을 막는다(2026-08-30 사용자).
|
||||
if (slotUsable(ownerSlot)) moveRows(panelRoot, SIDE_ROWS, ownerSlot);
|
||||
if (slotUsable(ownerSlot))
|
||||
moveRows(panelRoot, ownerSlot === sideSlots.outlet ? PIPE_OUTLET_ROWS : SIDE_ROWS, ownerSlot);
|
||||
} else {
|
||||
if (!slotUsable(target)) return;
|
||||
// 배관 유출 칸은 읽기 줄(관 길이 · 좌우 · 상하 안내)을 안 받음(54-1)
|
||||
const rows =
|
||||
target === sideSlots.outlet
|
||||
? SIDE_ROWS.replace(".b06-structure-panel__struct--readout, ", "")
|
||||
: SIDE_ROWS;
|
||||
// 배관 유출 칸은 읽기 줄(관 길이 · 좌우 · 상하 안내) · 단 수 줄을 안 받음(54-1 · 54-27)
|
||||
const rows = target === sideSlots.outlet ? PIPE_OUTLET_ROWS : SIDE_ROWS;
|
||||
moveRows(panelRoot, rows, target);
|
||||
}
|
||||
// 고른 구조물이 어느 쪽인지 그룹 테두리로 알린다(2026-08-29 사용자 지시 3).
|
||||
|
||||
@@ -44,8 +44,6 @@ export interface StructurePanelDeps {
|
||||
setForm: (key: RevetKey, form: string) => void;
|
||||
/** 자동 자리로 되돌린다(4축 모두). */
|
||||
reset: (key: RevetKey) => void;
|
||||
/** 지금 관 길이(m) — 조정 단위가 관 길이 1m이라 창에 같이 적는다. */
|
||||
pipeLengthM: () => number | null;
|
||||
/** 창을 닫는다 = 구조물 선택 해제. */
|
||||
close: () => void;
|
||||
/** 유입측 구조물 형식(드롭다운 값 — 2026-08-22 사용자). */
|
||||
@@ -155,16 +153,13 @@ export function buildStructurePanel(
|
||||
const title = document.createElement("span");
|
||||
title.className = "b06-structure-panel__title";
|
||||
|
||||
// 정보 표시(2026-08-22 사용자 — 항목당 한 행): 관 길이 / 좌우 / 상하.
|
||||
// 끄는 동안 미리 보기 글만 — 관 길이 · 좌우 · 상하 자동 자리 글은 뺌(54-28).
|
||||
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");
|
||||
const dragLine = dragReadout();
|
||||
value.append(pipeLine, lateralLine, slopeLine, dragLine);
|
||||
value.append(dragLine);
|
||||
|
||||
let current: RevetKey | null = null;
|
||||
const act = (run: (key: RevetKey) => void) => () => {
|
||||
@@ -630,41 +625,6 @@ export function buildStructurePanel(
|
||||
"is-hidden",
|
||||
!revetWall && linkState === null && currentSpan === null,
|
||||
);
|
||||
const adjust = deps.adjustFor(key);
|
||||
// 정보는 항목당 한 행(2026-08-22 사용자) — 0이면 "자동", 좌우는 안/바깥,
|
||||
// 상하는 위/아래로 적는다.
|
||||
const pipeLength = deps.pipeLengthM();
|
||||
pipeLine.classList.toggle("is-hidden", pipeLength === null);
|
||||
pipeLine.textContent =
|
||||
pipeLength === null ? "" : `${L("B06_Cross_Revet_Pipe")} ${pipeLength}m`;
|
||||
lateralLine.textContent =
|
||||
`${L("B06_Cross_Lateral_Label")} ` +
|
||||
(Math.abs(adjust.x) > 1e-9
|
||||
? `${L(adjust.x > 0 ? "B06_Cross_Revet_Outward" : "B06_Cross_Revet_Inward")} ${Math.abs(adjust.x).toFixed(1)}m`
|
||||
: L("B06_Cross_Revet_Auto"));
|
||||
// d = null → 자동(기본 자리). 숫자는 성토선 0점(벽 상단=노견) 기준 절대값
|
||||
// (배관 벽 — 2026-08-23). 0 = 성토선 0 지점.
|
||||
slopeLine.textContent =
|
||||
`${L("B06_Cross_Slope_Label")} ` +
|
||||
(adjust.d == null
|
||||
? L("B06_Cross_Revet_Auto")
|
||||
: 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();
|
||||
|
||||
@@ -184,7 +184,6 @@ export function createCrossSectionCard(
|
||||
let highlightRevet: RevetKey | null = revetOffset?.highlightFor(section) ?? activeRevet;
|
||||
let setRevetActive: RevetHighlightSetter = () => undefined;
|
||||
let showRevetControl: (visible: boolean) => void = () => undefined;
|
||||
let culvertPipeLengthM: number | null = null; // 관 길이(m) — 조정창 표기용
|
||||
let culvertInletIsBasin = false; // 유입이 집수정인가 — 조정창 표시·제목 분기
|
||||
// 이 카드의 기준벽이 **독립 기슭막이**인가(관 없는 숨김 세트). 조정창 걸음이
|
||||
// 배관(1m)과 다르다(0.1m — 2026-08-29 사용자).
|
||||
@@ -483,13 +482,7 @@ export function createCrossSectionCard(
|
||||
toDisplayY,
|
||||
);
|
||||
}
|
||||
// 배수관 세트 — 조정창이 쓸 카드 상태를 받아 둔다(2026-08-19). 관은 소유 측점
|
||||
// 전용이라 링크 카드에서는 길이를 비우고, 나머지는 링크 카드도 채운다(2026-08-24).
|
||||
// 숨김 기슭막이·링크 카드는 관이 없거나 옆에서 이어져 온 것 — 관 길이 표기 숨김.
|
||||
culvertPipeLengthM =
|
||||
isLinkedCulvert || culvertLayout?.culvert.hidden_pipe
|
||||
? null
|
||||
: (culvertLayout?.pipe.lengthM ?? null);
|
||||
// 배수관 세트 — 조정창이 쓸 카드 상태를 받아 둔다(2026-08-19) · 링크 카드도 채운다(2026-08-24).
|
||||
culvertInletIsBasin = !!culvertLayout?.basin;
|
||||
culvertHiddenPipe = culvertLayout?.culvert.hidden_pipe === true;
|
||||
if (culvertLayout) {
|
||||
@@ -621,7 +614,6 @@ export function createCrossSectionCard(
|
||||
drawnWallKeys: () => [...culvertWallSpecs.keys()],
|
||||
formOfWall,
|
||||
appliedD: () => culvertAppliedD,
|
||||
pipeLengthM: () => culvertPipeLengthM,
|
||||
inletIsBasin: () => culvertInletIsBasin,
|
||||
hiddenPipe: () => culvertHiddenPipe,
|
||||
inletOptions: () => culvertInletOptions,
|
||||
|
||||
@@ -64,7 +64,6 @@ export interface StructurePanelContext {
|
||||
formOfWall: (key: RevetKey) => string;
|
||||
/** 마지막 계산의 실제 적용 d(상하) — d 미지정 벽의 ▲▼ 시작값. */
|
||||
appliedD: () => Map<RevetKey, number>;
|
||||
pipeLengthM: () => number | null;
|
||||
inletIsBasin: () => boolean;
|
||||
/** 기준벽이 독립 기슭막이(관 없는 숨김 세트)인가 — 조정창 걸음 분류에 쓴다. */
|
||||
hiddenPipe: () => boolean;
|
||||
@@ -103,7 +102,6 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
|
||||
setForm: (key, form) => ctx.revetOffset?.update(ctx.adjustChainage(), key, { m: form }),
|
||||
heightFor: (key) => ctx.heightOfWall(key),
|
||||
reset: (key) => ctx.revetOffset?.reset(ctx.adjustChainage(), key),
|
||||
pipeLengthM: () => ctx.pipeLengthM(),
|
||||
close: () => {
|
||||
const active = ctx.activeRevet();
|
||||
if (active) ctx.toggleRevet(active);
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface StationControlDeps {
|
||||
onSaveError?: (message: string) => void;
|
||||
/** 다단 단 수가 바뀜 — 좌측 배관 흙막이 컨테이너 수를 맞춤(54-1). */
|
||||
onExtraCount?: () => void;
|
||||
/** 벽 조작값이 바뀜 — 좌측 관 길이(자동)를 다시 셈(54-28). */
|
||||
onWallAdjust?: () => void;
|
||||
}
|
||||
|
||||
/** 반폭·기슭막이·유입 구조물 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */
|
||||
@@ -206,6 +208,7 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
if (isDefaultAdjust(value)) delete bucket[role];
|
||||
else bucket[role] = { ...value };
|
||||
design.revet_adjust = bucket;
|
||||
deps.onWallAdjust?.();
|
||||
};
|
||||
const axisEq = (a: number | null, b: number | null): boolean =>
|
||||
a == null || b == null ? a === b : Math.abs(a - b) < 1e-9;
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/* 배관 유출 「추가 흙막이(단)」 칸(PLAN 54-27) 단위검증 헬퍼 — 실제 시설 폼(`createFacilityOptionsForm`)을
|
||||
* 그 자리에서 트랜스파일해 Node 로 돌린다. 단 수 읽기 · 쓰기(`design_structure_pipe_calc`)만 흉내 —
|
||||
* 쓰면 그 수가 곧 지형이 세운 단 수(한도 2). 결과는 표준출력에 JSON 한 벌. 짝 = `test_54_27_soil_tier_field.py`.
|
||||
*/
|
||||
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();
|
||||
|
||||
// ── 화면 그릇 흉내 — 태그 · 클래스 · data · 이벤트만 ─────────────────────────
|
||||
class FakeNode {
|
||||
constructor(tag) {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
this.style = {};
|
||||
this.parentElement = null;
|
||||
this.hidden = false;
|
||||
this.disabled = false;
|
||||
this._classes = new Set();
|
||||
this._listeners = {};
|
||||
this._text = "";
|
||||
this.value = "";
|
||||
const self = this;
|
||||
this.classList = {
|
||||
add: (...n) => n.forEach((c) => self._classes.add(c)),
|
||||
remove: (...n) => n.forEach((c) => self._classes.delete(c)),
|
||||
toggle: (c, f) =>
|
||||
(f ?? !self._classes.has(c)) ? self._classes.add(c) : self._classes.delete(c),
|
||||
contains: (c) => self._classes.has(c),
|
||||
};
|
||||
}
|
||||
set className(v) {
|
||||
this._classes = new Set(String(v).split(/\s+/).filter(Boolean));
|
||||
}
|
||||
set textContent(v) {
|
||||
this._text = String(v);
|
||||
this.children = [];
|
||||
}
|
||||
get textContent() {
|
||||
return this._text + this.children.map((c) => c.textContent ?? "").join("");
|
||||
}
|
||||
get childElementCount() {
|
||||
return this.children.length;
|
||||
}
|
||||
append(...kids) {
|
||||
kids.forEach((k) => {
|
||||
const node = typeof k === "string" ? { textContent: k } : k;
|
||||
if (node.parentElement)
|
||||
node.parentElement.children.splice(node.parentElement.children.indexOf(node), 1);
|
||||
node.parentElement = this;
|
||||
this.children.push(node);
|
||||
});
|
||||
}
|
||||
replaceChildren(...kids) {
|
||||
this.children.forEach((k) => (k.parentElement = null));
|
||||
this.children = [];
|
||||
this.append(...kids);
|
||||
}
|
||||
add(kid) {
|
||||
this.append(kid);
|
||||
}
|
||||
setAttribute(n, v) {
|
||||
this[n] = v;
|
||||
}
|
||||
addEventListener(type, fn) {
|
||||
(this._listeners[type] ??= []).push(fn);
|
||||
}
|
||||
dispatchEvent(event) {
|
||||
(this._listeners[event.type] ?? []).forEach((fn) => fn(event));
|
||||
}
|
||||
closest() {
|
||||
return null;
|
||||
}
|
||||
matches(sel) {
|
||||
return sel.split(",").some((s) => {
|
||||
s = s.trim();
|
||||
return s.startsWith(".") ? this._classes.has(s.slice(1)) : this.tagName === s.toUpperCase();
|
||||
});
|
||||
}
|
||||
querySelectorAll(sel) {
|
||||
const found = [];
|
||||
const walk = (n) =>
|
||||
(n.children ?? []).forEach((k) => {
|
||||
if (k.matches?.(sel)) found.push(k);
|
||||
walk(k);
|
||||
});
|
||||
walk(this);
|
||||
return found;
|
||||
}
|
||||
querySelector(sel) {
|
||||
return this.querySelectorAll(sel)[0] ?? null;
|
||||
}
|
||||
}
|
||||
globalThis.document = {
|
||||
createElement: (t) => new FakeNode(t),
|
||||
createComment: () => new FakeNode("#comment"),
|
||||
};
|
||||
globalThis.Event = function (type) {
|
||||
this.type = type;
|
||||
};
|
||||
globalThis.queueMicrotask = (fn) => fn();
|
||||
globalThis.Option = function (text, value) {
|
||||
const o = new FakeNode("option");
|
||||
o.textContent = text;
|
||||
o.value = value;
|
||||
return o;
|
||||
};
|
||||
|
||||
// 단 수 자리 흉내 — 쓰면 지형 한도(2)까지만 섬
|
||||
const LIMIT = 2;
|
||||
let stored = 0;
|
||||
const writes = [];
|
||||
const PIPE_CALC = {
|
||||
guardHeightHint: () => "",
|
||||
pipeDrawn: () => null,
|
||||
pipeLengthM: () => null,
|
||||
pipeExtraCount: () => Math.min(stored, LIMIT),
|
||||
setPipeExtraCount: (chainage, count) => {
|
||||
writes.push([chainage, count]);
|
||||
stored = count;
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
const STUBS = {
|
||||
"@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 },
|
||||
"../A00_Common/design/design_structure_pipe_calc": PIPE_CALC,
|
||||
"../../B05_Profile/B05_Profile_UI_Structures_Summary_Row": {
|
||||
buildSummaryRow: () => {
|
||||
const root = new FakeNode("div");
|
||||
const label = new FakeNode("span");
|
||||
label.className = "b05-structure__summary-label";
|
||||
root.append(label);
|
||||
return { root, set: () => {}, bind: () => {} };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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 { createFacilityOptionsForm } = loadTs(
|
||||
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Drainage_Facility.ts"),
|
||||
);
|
||||
const form = createFacilityOptionsForm();
|
||||
form.setChainage(100);
|
||||
form.setFacility("pipe", { pipe_diameter_mm: 1000 });
|
||||
|
||||
const spec = form.root.querySelector(".b05-structure__spec");
|
||||
const boxesOf = (id) => spec.children.filter((k) => k.dataset?.specContainer === id);
|
||||
const tierRow = () =>
|
||||
boxesOf("outlet")[0]
|
||||
?.querySelectorAll("label")
|
||||
.find((l) => l.dataset.specItem === "outlet.tiers");
|
||||
const tierInput = () => tierRow()?.querySelector("input");
|
||||
const snap = () => ({
|
||||
value: tierInput()?.value ?? null,
|
||||
boxes: boxesOf("soil_add").length,
|
||||
structures: boxesOf("soil_add").map((b) => b.querySelector("select")?.children[0]?.textContent),
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const out = { label: tierRow()?.children[0]?.textContent ?? null, start: snap() };
|
||||
for (const n of ["2", "1", "5", "0"]) {
|
||||
const input = tierInput();
|
||||
input.value = n;
|
||||
input.dispatchEvent(new Event("change"));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
out[`set${n}`] = snap();
|
||||
}
|
||||
// 횡단 조정에서 바꿈 → 좌측 칸 · 상자 따라감(B06 이 recount 를 부름)
|
||||
stored = 2;
|
||||
form.recount();
|
||||
out.fromCross = snap();
|
||||
out.writes = writes;
|
||||
// 다른 구조(옛 값)면 칸을 가림
|
||||
form.setFacility("pipe", { pipe_diameter_mm: 1000, outlet_type: "옛값" });
|
||||
out.otherHidden = tierRow()?.hidden ?? null;
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
}
|
||||
main().catch((error) => {
|
||||
process.stderr.write(String(error?.stack ?? error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
"""배관 유출 「추가 흙막이(단)」 칸 (PLAN 54-27).
|
||||
|
||||
짝: `B05_Profile/B05_Profile_UI_Drainage_Facility.ts`(유출 상자 안 단 칸 · 흙막이 상자 구조 「흙막이 (기본)」) ·
|
||||
`A00_Common/design/design_structure_pipe_calc.ts`(`setPipeExtraCount` = 횡단 조정 단 수와 같은 자리).
|
||||
실제 TS 를 `helper_54_27_soil_tier_field.cjs` 가 Node 로 돌린다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", os.path.join(HERE, "helper_54_27_soil_tier_field.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:
|
||||
assert out["label"] == "추가 흙막이(단)"
|
||||
assert out["start"] == {"value": "0", "boxes": 0, "structures": []}
|
||||
|
||||
|
||||
def test_단_수_N_이면_흙막이_상자_N(out) -> None:
|
||||
assert (out["set2"]["value"], out["set2"]["boxes"]) == ("2", 2)
|
||||
assert (out["set1"]["value"], out["set1"]["boxes"]) == ("1", 1)
|
||||
assert (out["set0"]["value"], out["set0"]["boxes"]) == ("0", 0)
|
||||
|
||||
|
||||
def test_지형_한도를_넘으면_세운_만큼(out) -> None:
|
||||
assert (out["set5"]["value"], out["set5"]["boxes"]) == ("2", 2)
|
||||
|
||||
|
||||
def test_좌측_칸은_횡단_단_수_자리에_씀(out) -> None:
|
||||
assert out["writes"] == [[100, 2], [100, 1], [100, 5], [100, 0]]
|
||||
|
||||
|
||||
def test_횡단에서_바꾸면_좌측_칸이_따라감(out) -> None:
|
||||
assert (out["fromCross"]["value"], out["fromCross"]["boxes"]) == ("2", 2)
|
||||
|
||||
|
||||
def test_늘어난_흙막이_상자_구조는_흙막이_기본(out) -> None:
|
||||
assert out["set2"]["structures"] == ["흙막이 (기본)", "흙막이 (기본)"]
|
||||
|
||||
|
||||
def test_흙막이_아닌_구조면_칸을_가림(out) -> None:
|
||||
assert out["otherHidden"] is True
|
||||
|
||||
|
||||
def _src(rel: str) -> str:
|
||||
with open(os.path.join(HERE, "..", "..", rel), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_자동_자리_읽기_글이_없음() -> None:
|
||||
"""54-28 — 관 길이 · 좌우 바깥 · 상하(사면) 자동 자리 글을 만드는 뿌리(문구 · 줄)가 없음."""
|
||||
locale = _src("ui_template/ui_template_locale_b2.ts")
|
||||
for key in ("B06_Cross_Revet_Auto", "B06_Cross_Revet_Pipe", "B06_Cross_Lateral_Label"):
|
||||
assert key not in locale
|
||||
assert "lateralLine" not in _src("B06_Section/B06_Section_UI_Cross_Structure_Panel.ts")
|
||||
|
||||
|
||||
def test_배관_유출_칸은_옛_단_수_줄을_안_받음() -> None:
|
||||
dock = _src("B06_Section/B06_Section_UI_Adjust_Dock.ts")
|
||||
assert '".b06-structure-panel__struct--tier, ",\n "",' in dock
|
||||
@@ -243,10 +243,6 @@ export const ui_locales_b2 = {
|
||||
],
|
||||
B06_Cross_Revet_Label: ["기슭막이", "Revetment"],
|
||||
B06_Cross_Revet_Close: ["조정창 닫기", "Close adjuster"],
|
||||
B06_Cross_Revet_Auto: ["자동 자리", "Solved position"],
|
||||
B06_Cross_Revet_Pipe: ["관 길이", "Pipe length"],
|
||||
B06_Cross_Revet_Outward: ["바깥", "outward"],
|
||||
B06_Cross_Revet_Inward: ["안쪽", "inward"],
|
||||
B06_Cross_Revet_Left: ["왼쪽으로 — 관 길이 1m 단위", "Move left — 1m of pipe length"],
|
||||
B06_Cross_Revet_Right: ["오른쪽으로 — 관 길이 1m 단위", "Move right — 1m of pipe length"],
|
||||
B06_Cross_Revet_Reset: ["기슭막이 자동 자리로 초기화", "Reset revetment to solved position"],
|
||||
@@ -259,8 +255,6 @@ export const ui_locales_b2 = {
|
||||
B06_Cross_Revet_Extra: ["기슭막이(추가 {n})", "Revetment (extra {n})"],
|
||||
B06_Cross_Revet_Up: ["사면 위로(대각) 1m", "Up along fill slope 1m"],
|
||||
B06_Cross_Revet_Down: ["사면 아래로(대각) 1m", "Down along fill slope 1m"],
|
||||
B06_Cross_Revet_DirUp: ["위", "up"],
|
||||
B06_Cross_Revet_DirDown: ["아래", "down"],
|
||||
B06_Cross_Revet_Limit_Up: [
|
||||
"사면 위로는 여기까지입니다 — 최소 성토고·토피(역경사) 한계",
|
||||
"Cannot move further up the slope — min fill height / cover limit",
|
||||
@@ -271,8 +265,6 @@ export const ui_locales_b2 = {
|
||||
],
|
||||
B06_Cross_Height_Label: ["높이", "Height"],
|
||||
B06_Cross_Move_Label: ["이동(좌우·사면 상하)", "Move (lateral / along slope)"],
|
||||
B06_Cross_Lateral_Label: ["좌우", "Lateral"],
|
||||
B06_Cross_Slope_Label: ["상하(사면)", "Along slope"],
|
||||
B06_Cross_Height_Minus: ["높이 −0.1m", "Height −0.1m"],
|
||||
B06_Cross_Height_Plus: ["높이 +0.1m", "Height +0.1m"],
|
||||
B06_Cross_Height_Limit: [
|
||||
|
||||
Reference in New Issue
Block a user