Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1
This commit is contained in:
@@ -56,7 +56,8 @@ export interface SpecFormHooks {
|
||||
hint?: (c: SpecContainer, item: SpecItem, state: SpecState) => string;
|
||||
onChange?: (c: SpecContainer, item: SpecItem) => void;
|
||||
/** 선택 컨테이너 개수를 밖에서 정함(54-1 배관 흙막이 = 추가 기슭막이(단)) — 수면 그만큼 늘 보임 ·
|
||||
* [추가] · [빼기] 없음 · 줄면 끝에서부터 빠짐 · null = 모름(저장된 것 그대로) · undefined = 손 [추가]. */
|
||||
* [추가] · [빼기] 없음 · 줄면 끝에서부터 가림(값은 남음 · 54-10) · null = 모름(저장된 것 그대로) ·
|
||||
* undefined = 손 [추가]. */
|
||||
count?: (c: SpecContainer) => number | null | undefined;
|
||||
}
|
||||
|
||||
@@ -228,15 +229,18 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
||||
return box.root;
|
||||
};
|
||||
|
||||
/** 밖에서 정한 개수로 맞춤 — 줄면 끝 인스턴스 · 잇기를 뺌. 바뀌었으면 true. */
|
||||
/** 밖에서 정한 개수 — 모자라면 빈 인스턴스를 늘림(늘었으면 true) · 넘친 인스턴스 · 잇기는
|
||||
* 가리기만 하고 값에 남김(54-10 · 파일에 그대로). */
|
||||
const fit = (c: SpecContainer, list: Instance[]): boolean => {
|
||||
const n = hooks.count?.(c);
|
||||
if (c.required || n === undefined || n === null || n === list.length) return false;
|
||||
for (let k = n + 1; k <= list.length; k++) links?.set(linkOf(c, k), null);
|
||||
list.splice(n);
|
||||
if (c.required || n === undefined || n === null || n <= list.length) return false;
|
||||
while (list.length < n) list.push({});
|
||||
return true;
|
||||
};
|
||||
const shown = (c: SpecContainer, list: Instance[]): Instance[] => {
|
||||
const n = c.required ? null : hooks.count?.(c);
|
||||
return n === undefined || n === null ? list : list.slice(0, n);
|
||||
};
|
||||
|
||||
function paint(quiet = true): void {
|
||||
rows.length = 0;
|
||||
@@ -245,7 +249,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
||||
for (const c of spec?.containers ?? []) {
|
||||
const list = (state[c.id] ??= c.required ? [{}] : []);
|
||||
if (fit(c, list)) fitted.push(c);
|
||||
list.forEach((values, index) => boxes.push(instanceBox(c, values, index)));
|
||||
shown(c, list).forEach((values, index) => boxes.push(instanceBox(c, values, index)));
|
||||
if (c.required || (c.max && list.length >= c.max) || hooks.count?.(c) !== undefined) continue;
|
||||
const add = document.createElement("button");
|
||||
add.type = "button";
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 셈 · 시험 test_b06_pipe_length) 그대로 — 여기서 새로 셈하지 않음 · 화면에 보이기만(저장 안 함).
|
||||
* 높이 = 손 입력 · 횡단도 조정 높이는 B06 `withSpecDefaults` 가 같은 키(`*_revet_height_m`)에 실음 ·
|
||||
* 빈 칸 안내 = 관경 기준 최소 높이(`pipeWallMinHeightM` ↔ 파이썬 `pipe_wall_min_height_m` 짝).
|
||||
* 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」(횡단 설계 patch `extra_wall_counts` · 54-1) — 읽기만.
|
||||
* 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」(횡단 설계 patch `extra_wall_counts` · 54-1) 중 지형이 세운 단(54-10) — 읽기만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pipeWallMinHeightM } from "../../common_util/common_util_culvert_sets";
|
||||
@@ -51,11 +51,17 @@ export function pipeLengthM(chainageM: number | null): number | null {
|
||||
return typeof length === "number" && length > 0 ? length : null;
|
||||
}
|
||||
|
||||
/** 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」 — 횡단 자료가 없으면 null(모름). */
|
||||
/** 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」 중 지형이 세운 단(54-10) — 횡단 자료가 없으면
|
||||
* null(모름). 저장 단 수가 더 커도 넘친 단은 컨테이너를 안 세움(값은 파일에 그대로). */
|
||||
export function pipeExtraCount(chainageM: number | null): number | null {
|
||||
const owner = ownerSection(chainageM)?.owner;
|
||||
if (!owner) return null;
|
||||
return extraCountSource?.(owner) ?? owner.design?.extra_wall_counts?.outlet ?? 0;
|
||||
const found = ownerSection(chainageM);
|
||||
if (!found) return null;
|
||||
const { owner, sections } = found;
|
||||
const outlet = extraCountSource?.(owner) ?? owner.design?.extra_wall_counts?.outlet ?? 0;
|
||||
if (!outlet || !owner.design) return outlet;
|
||||
const counts = { basin: 0, ...owner.design.extra_wall_counts, outlet };
|
||||
const probe = { ...owner, design: { ...owner.design, extra_wall_counts: counts } };
|
||||
return computeStoredLayouts(probe, sections)?.culvert?.extraWalls.length ?? outlet;
|
||||
}
|
||||
|
||||
/** 흙막이 높이 빈 칸 안내 — 관경 기준 최소 높이(엔진이 빈 칸을 이 값으로 그림). */
|
||||
|
||||
@@ -41,7 +41,7 @@ import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection";
|
||||
import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick";
|
||||
import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View";
|
||||
import { revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const";
|
||||
import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const";
|
||||
import {
|
||||
applyPipeOptionsToCache,
|
||||
replayDraftPipeOptions,
|
||||
@@ -73,7 +73,11 @@ import {
|
||||
createStandardSlot,
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Panel";
|
||||
import type { DesignPageHost } from "./design_page";
|
||||
import { setPipeExtraCountSource, setPipeSectionsSource } from "./design_structure_pipe_calc";
|
||||
import {
|
||||
pipeExtraCount,
|
||||
setPipeExtraCountSource,
|
||||
setPipeSectionsSource,
|
||||
} from "./design_structure_pipe_calc";
|
||||
import {
|
||||
bindDesignView,
|
||||
bindPipeOptions,
|
||||
@@ -152,6 +156,7 @@ export async function mountCrossView(
|
||||
// 조작(조정창 높이)이 있을 때만 — 없으면 그려진 높이는 관경 기준 제안이라 옵션에 안 적음
|
||||
// (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 안 바꿈」).
|
||||
if (adjust?.h == null) return null;
|
||||
if (role !== "inlet" && role !== "outlet") return adjust.h; // 다단 = 기하가 되받은 적용 높이(54-10)
|
||||
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m)
|
||||
.pureHeight;
|
||||
},
|
||||
@@ -164,6 +169,7 @@ export async function mountCrossView(
|
||||
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
|
||||
const adjust = stationControls.revetOffset.adjustFor(owner, role);
|
||||
if (!adjust?.m) return null; // 조작한 형태만 — 기본 형태는 제안(판정 ④)
|
||||
if (role !== "inlet" && role !== "outlet") return revetFormOf(adjust.m); // 다단(54-10)
|
||||
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m).form;
|
||||
},
|
||||
// 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다.
|
||||
@@ -333,6 +339,8 @@ export async function mountCrossView(
|
||||
ford: stationControls.ford,
|
||||
box: stationControls.box,
|
||||
revetOffset: stationControls.revetOffset,
|
||||
structureSpan: stationControls.structureSpan,
|
||||
soilTiers: pipeExtraCount,
|
||||
overrideOptions: (chainageM, values) => structuresPanel.overrideOptions(chainageM, values),
|
||||
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
||||
};
|
||||
|
||||
@@ -145,6 +145,27 @@ function relocateSideRows(panelRoot: HTMLElement): void {
|
||||
const group = target.closest<HTMLElement>("fieldset");
|
||||
if (!group) return;
|
||||
const ownerSlot = slotOf(panelRoot.dataset.extraSide === "inlet" ? "inlet" : "outlet");
|
||||
// 배관 유출 다단 = 그 단의 「흙막이」 컨테이너(54-10) — 옛 추가 기슭막이 칸 대신 그 상자를 켬 ·
|
||||
// 단 수 · 옵션은 부모(유출) 칸에 그대로
|
||||
const soil =
|
||||
ownerSlot === sideSlots.outlet && slotUsable(ownerSlot)
|
||||
? ownerSlot
|
||||
.closest(".b05-structure__spec")
|
||||
?.querySelectorAll<HTMLElement>('fieldset[data-spec-container="soil_add"]')[
|
||||
Number(panelRoot.dataset.extraIndex)
|
||||
]
|
||||
: undefined;
|
||||
if (soil) {
|
||||
moveRows(
|
||||
panelRoot,
|
||||
SIDE_ROWS.replace(".b06-structure-panel__struct--readout, ", ""),
|
||||
ownerSlot,
|
||||
);
|
||||
setGroupLabel(soil, "");
|
||||
soil.classList.add("is-active");
|
||||
soil.scrollIntoView({ block: "nearest" });
|
||||
return;
|
||||
}
|
||||
const owner = ownerSlot.closest("fieldset");
|
||||
if (owner && owner !== group) owner.after(group);
|
||||
group.hidden = false;
|
||||
|
||||
@@ -588,6 +588,7 @@ export function buildStructurePanel(
|
||||
// 이름 자체로(2026-08-30 사용자 지시 1). 어느 구조물을 만지는지 폼에서 읽힌다.
|
||||
// 다단은 칸 이름이 이미 "추가 기슭막이"라 단 번호만 붙인다 — "추가 기슭막이 (2단)".
|
||||
root.dataset.panelTitle = isExtra ? `${extraIndex + 1}단` : (title.textContent ?? "");
|
||||
root.dataset.extraIndex = isExtra ? String(extraIndex) : ""; // 흙막이 컨테이너 차례(54-10)
|
||||
const tiers =
|
||||
tierChannel === "outlet"
|
||||
? deps.extraState()
|
||||
|
||||
@@ -12,7 +12,15 @@ import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { applyBoxFormOptions, applyFordFormOptions } from "./B06_Section_UI_Page_Ford_Controls";
|
||||
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import {
|
||||
applySpanPatch,
|
||||
EXTRA_WALL_MIN_HEIGHT_M,
|
||||
REVET_EMBED_DEPTH_M,
|
||||
revetFormOf,
|
||||
revetHeightLimit,
|
||||
revetWallSpec,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||
|
||||
type StationControls = ReturnType<typeof createStationControls>;
|
||||
@@ -23,6 +31,10 @@ export interface PipeOptionsContext {
|
||||
ford: StationControls["ford"];
|
||||
box: StationControls["box"];
|
||||
revetOffset: StationControls["revetOffset"];
|
||||
/** 다단 단별 구간값 — 흙막이 컨테이너 길이 · 전 · 후(54-10). */
|
||||
structureSpan?: StationControls["structureSpan"];
|
||||
/** 흙막이 컨테이너 수 = 지형이 세운 유출 다단 수(`pipeExtraCount` · 54-10) — 없으면 다단 안 만짐. */
|
||||
soilTiers?: (chainageM: number) => number | null;
|
||||
/** 기하가 잘라 낸 실제 값을 폼에 되돌린다. */
|
||||
overrideOptions: (chainageM: number, values: Record<string, number | string>) => void;
|
||||
refreshCard: (chainageM: number) => void;
|
||||
@@ -129,6 +141,7 @@ export function applyPipeOptionsToCache(
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!culvert.hidden_pipe) applySoilTiers(ctx, owner, patch, whole);
|
||||
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"));
|
||||
@@ -149,6 +162,89 @@ export function applyPipeOptionsToCache(
|
||||
}
|
||||
}
|
||||
|
||||
/** 흙막이 컨테이너가 마지막으로 보낸 높이 · 형태(`측점:extra{n}`) — 기하가 다시 그릴 때마다 키를 지형에
|
||||
* 맞춰 고쳐 되받으므로(syncApplied) 되받은 값과 견주면 안 바꾼 단도 다시 보냄(54-10). */
|
||||
const soilSent = new Map<string, { h: number | null; m: string | null }>();
|
||||
|
||||
/**
|
||||
* 흙막이 n 컨테이너(`soil_add_{n}_*`) → 유출 다단 n 단(`extra{n-1}`) 그림(54-10). 형태 · 높이 = 조정값
|
||||
* (m · h · 빈 칸 = 자동) · 길이 · 전 · 후 = 단별 구간값(빈 칸 = 그대로). 지형이 세운 단까지만 —
|
||||
* 넘친 컨테이너 값은 파일에만 남음. 높이 = 요청 · 서는 키는 기하 규칙 그대로(지형 맞춤 · 형태 한계) ·
|
||||
* 형태 한계 · 하한을 넘은 요청만 폼에 되돌림.
|
||||
*/
|
||||
function applySoilTiers(
|
||||
ctx: PipeOptionsContext,
|
||||
owner: CrossSection,
|
||||
patch: Record<string, number | string>,
|
||||
whole: boolean,
|
||||
): void {
|
||||
const culvert = owner.culvert!;
|
||||
const ownerForm = revetWallSpec(
|
||||
culvert.outlet,
|
||||
ctx.revetOffset.adjustFor(owner, "outlet"),
|
||||
false,
|
||||
culvert.diameter_m,
|
||||
).form;
|
||||
const value = (n: number, item: string): number | undefined => {
|
||||
const raw = patch[`soil_add_${n}_${item}`];
|
||||
return raw === undefined || raw === "" || !Number.isFinite(Number(raw))
|
||||
? undefined
|
||||
: Number(raw);
|
||||
};
|
||||
const blank = whole ? null : undefined;
|
||||
const clipped: Record<string, number> = {};
|
||||
for (let n = 1; n <= (ctx.soilTiers?.(owner.chainage_m) ?? 0); n++) {
|
||||
const role = `extra${n - 1}` as const;
|
||||
const key = `${owner.chainage_m.toFixed(2)}:${role}`;
|
||||
const form = patch[`soil_add_${n}_form`];
|
||||
const want = {
|
||||
h: value(n, "height") ?? blank,
|
||||
m: typeof form === "string" && form ? form : blank,
|
||||
};
|
||||
if (want.h != null) {
|
||||
const limit = revetHeightLimit(
|
||||
revetFormOf(want.m ?? ctx.revetOffset.adjustFor(owner, role).m, ownerForm),
|
||||
);
|
||||
const h = Math.min(
|
||||
Math.max(want.h, EXTRA_WALL_MIN_HEIGHT_M, REVET_EMBED_DEPTH_M + 0.1),
|
||||
limit,
|
||||
);
|
||||
if (Math.abs(h - want.h) > 0.05) {
|
||||
clipped[`soil_add_${n}_height`] = Number(h.toFixed(1));
|
||||
showToast(
|
||||
`흙막이 ${n} 높이는 ${h < want.h ? "형태 한계로" : "최소"} ${h.toFixed(1)}m` +
|
||||
`${h < want.h ? "까지만" : "로"} 적용됩니다.`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
want.h = Number(h.toFixed(1));
|
||||
}
|
||||
const sent = soilSent.get(key) ?? ctx.revetOffset.adjustFor(owner, role);
|
||||
if (
|
||||
(want.h !== undefined && want.h !== sent.h) ||
|
||||
(want.m !== undefined && want.m !== sent.m)
|
||||
) {
|
||||
ctx.revetOffset.update(owner.chainage_m, role, want);
|
||||
soilSent.set(key, { h: want.h ?? sent.h, m: want.m ?? sent.m });
|
||||
}
|
||||
const span = {
|
||||
lengthM: value(n, "length"),
|
||||
beforeM: value(n, "before"),
|
||||
afterM: value(n, "after"),
|
||||
};
|
||||
if (!ctx.structureSpan || Object.values(span).every((v) => v === undefined)) continue;
|
||||
const current = ctx.structureSpan.tierValuesFor(owner, role);
|
||||
const next = applySpanPatch(current, span);
|
||||
if (
|
||||
(["lengthM", "beforeM", "afterM"] as const).some((k) => Math.abs(next[k] - current[k]) > 1e-6)
|
||||
)
|
||||
ctx.structureSpan.updateTier(owner, role, span);
|
||||
}
|
||||
// 폼에 되돌림은 이 변경을 패널이 옵션에 담은 뒤 — 지금 되돌리면 옛 옵션으로 폼을 다시 세움
|
||||
if (Object.keys(clipped).length)
|
||||
queueMicrotask(() => ctx.overrideOptions(owner.chainage_m, clipped));
|
||||
}
|
||||
|
||||
/** 조정창(기슭막이 조정값)이 정본인 칸 — 초안에서 되살리지 않는다. */
|
||||
const ADJUST_OWNED = /_revet_(height_m|form)$/;
|
||||
|
||||
|
||||
@@ -78,9 +78,9 @@ export interface B06StructuresPanelDeps {
|
||||
detail?: () => SectionDetailResponse | null;
|
||||
/** 지금 그려진 벽 높이(m) — 배관 벽은 스펙이 아니라 기하가 정하므로 폼 기본
|
||||
* 높이도 이 값을 쓴다(조정창 표시값과 같다). */
|
||||
wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null;
|
||||
wallHeight?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => number | null;
|
||||
/** 지금 그려진 벽 형태 — 조정값(m)이 있으면 그쪽이 정본이라 폼도 이 값을 보인다. */
|
||||
wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null;
|
||||
wallForm?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => string | null;
|
||||
/** 그 측점에서 유입·유출 벽이 각각 좌·우 어느 쪽인가(독립 기슭막이 칸 이름표). */
|
||||
wallSideLabels?: (chainageM: number) => { inlet: "좌" | "우"; outlet: "좌" | "우" } | null;
|
||||
/** 측점 간격(m) — 측점번호+잔여거리 환산용. */
|
||||
|
||||
@@ -140,8 +140,8 @@ export function withSpecDefaults(
|
||||
detail: SectionDetailResponse | null,
|
||||
chainageM: number,
|
||||
types: StructureType[],
|
||||
wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null,
|
||||
wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null,
|
||||
wallHeight?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => number | null,
|
||||
wallForm?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => string | null,
|
||||
): Merged | undefined {
|
||||
const owner = detail?.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M,
|
||||
@@ -160,5 +160,16 @@ export function withSpecDefaults(
|
||||
const drawnHeight = wallHeight?.(chainageM, prefix);
|
||||
if (drawnHeight != null) merged[`${prefix}_revet_height_m`] = Number(drawnHeight.toFixed(1));
|
||||
}
|
||||
// 다단 n 단 조작값 → 흙막이 n 컨테이너(54-10) — 앞 컨테이너가 비었으면 자리만 둠(차례 지킴)
|
||||
for (let i = 0; i < 9 && !culvert.hidden_pipe; i++) {
|
||||
const form = wallForm?.(chainageM, `extra${i}`);
|
||||
const height = wallHeight?.(chainageM, `extra${i}`);
|
||||
if (!form && height == null) continue;
|
||||
for (let n = 1; n <= i; n++)
|
||||
if (!Object.keys(merged).some((key) => key.startsWith(`soil_add_${n}_`)))
|
||||
merged[`soil_add_${n}_structure`] = "";
|
||||
if (form) merged[`soil_add_${i + 1}_form`] = form;
|
||||
if (height != null) merged[`soil_add_${i + 1}_height`] = Number(height.toFixed(1));
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/* 다단 흙막이 ↔ 흙막이 컨테이너(PLAN 54-10) 단위검증 헬퍼 — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다.
|
||||
* ① 폼 → 다단 그림(`applyPipeOptionsToCache`) ② 밖에서 정한 개수보다 많은 컨테이너 값은 가리기만(`createSpecForm`).
|
||||
* 결과는 표준출력에 JSON 한 벌. 짝 = `test_54_10_soil_tiers.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._classes = new Set();
|
||||
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("");
|
||||
}
|
||||
append(...kids) {
|
||||
kids.forEach((k) => this.children.push(typeof k === "string" ? { textContent: k } : k));
|
||||
}
|
||||
replaceChildren(...kids) {
|
||||
this.children = [];
|
||||
this.append(...kids);
|
||||
}
|
||||
add(kid) {
|
||||
this.children.push(kid);
|
||||
}
|
||||
setAttribute(n, v) {
|
||||
this[n] = v;
|
||||
}
|
||||
addEventListener() {}
|
||||
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) };
|
||||
globalThis.queueMicrotask = (fn) => fn(); // 폼 되돌림을 바로 받아 봄
|
||||
globalThis.Option = function (text, value) {
|
||||
const o = new FakeNode("option");
|
||||
o.textContent = text;
|
||||
o.value = value;
|
||||
return o;
|
||||
};
|
||||
|
||||
const toasts = [];
|
||||
const STUBS = {
|
||||
"@ui/ui_template_elements": { showToast: (msg) => toasts.push(msg) },
|
||||
"@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 },
|
||||
"./B06_Section_UI_Page_Ford_Controls": {
|
||||
applyFordFormOptions: () => {},
|
||||
applyBoxFormOptions: () => {},
|
||||
},
|
||||
"../../B05_Profile/B05_Profile_UI_Structures_Summary_Row": { buildSummaryRow: () => null },
|
||||
};
|
||||
|
||||
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 out = {};
|
||||
|
||||
// ── ① 폼 → 다단 그림 ──────────────────────────────────────────────────────
|
||||
const { applyPipeOptionsToCache } = loadTs(
|
||||
path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Pipe_Options.ts"),
|
||||
);
|
||||
const adjusts = {};
|
||||
const updates = [];
|
||||
const tiers = [];
|
||||
const overrides = [];
|
||||
const section = {
|
||||
chainage_m: 100,
|
||||
culvert: { diameter_m: 1, hidden_pipe: false, inlet: {}, outlet: { revet_form: "돌쌓기(찰)" } },
|
||||
};
|
||||
const ctx = {
|
||||
detail: () => ({ cross_sections: [section] }),
|
||||
ford: {},
|
||||
box: {},
|
||||
revetOffset: {
|
||||
adjustFor: (_s, role) => adjusts[role] ?? { x: 0, d: null, h: null, m: null },
|
||||
update: (_c, role, patch) => {
|
||||
updates.push({ role, ...patch });
|
||||
const now = adjusts[role] ?? { x: 0, d: null, h: null, m: null };
|
||||
const m = patch.m === undefined ? now.m : patch.m;
|
||||
// 기하 되받기(syncApplied) 흉내 — 형태 한계로 자름(돌쌓기(메) 2.0)
|
||||
const h =
|
||||
patch.h == null
|
||||
? patch.h === undefined
|
||||
? now.h
|
||||
: null
|
||||
: Math.min(patch.h, m === "돌쌓기(메)" ? 2.0 : 5.0);
|
||||
adjusts[role] = { ...now, h, m };
|
||||
},
|
||||
},
|
||||
structureSpan: {
|
||||
tierValuesFor: () => ({ lengthM: 10, beforeM: 5, afterM: 5 }),
|
||||
updateTier: (_s, key, patch) => tiers.push({ key, ...patch }),
|
||||
},
|
||||
soilTiers: () => 2,
|
||||
overrideOptions: (_c, values) => overrides.push(values),
|
||||
refreshCard: () => {},
|
||||
};
|
||||
applyPipeOptionsToCache(ctx, 100, {
|
||||
soil_add_1_form: "콘크리트",
|
||||
soil_add_1_height: 1.8,
|
||||
soil_add_1_length: 8,
|
||||
soil_add_1_before: 3,
|
||||
soil_add_1_after: 5,
|
||||
soil_add_2_form: "돌쌓기(메)",
|
||||
soil_add_2_height: 2.5,
|
||||
soil_add_3_height: 1.2, // 지형이 못 세운 단 — 그림에 안 감
|
||||
});
|
||||
out.filled = { updates: updates.splice(0), tiers: tiers.splice(0), overrides: overrides.splice(0) };
|
||||
out.toasts = toasts.splice(0);
|
||||
// 같은 값을 다시 보내면 다시 그리지 않음
|
||||
applyPipeOptionsToCache(ctx, 100, {
|
||||
soil_add_1_form: "콘크리트",
|
||||
soil_add_1_height: 1.8,
|
||||
soil_add_2_form: "돌쌓기(메)",
|
||||
soil_add_2_height: 2.0,
|
||||
});
|
||||
out.same = { updates: updates.splice(0), tiers: tiers.splice(0) };
|
||||
// 비운 칸 = 자동(높이 · 형태) · 구간값은 그대로
|
||||
applyPipeOptionsToCache(ctx, 100, {});
|
||||
out.cleared = { updates: updates.splice(0), tiers: tiers.splice(0) };
|
||||
// 독립 기슭막이(관 숨김)는 다단을 안 만짐
|
||||
section.culvert.hidden_pipe = true;
|
||||
applyPipeOptionsToCache(ctx, 100, { soil_add_1_height: 1.0 });
|
||||
out.hidden = updates.splice(0);
|
||||
|
||||
// ── ② 개수보다 많은 컨테이너 값은 가리기만 · 값은 남김 ───────────────────────
|
||||
const { createSpecForm } = loadTs(
|
||||
path.join(ROOT, "A00_Common", "design", "design_structure_form.ts"),
|
||||
);
|
||||
const { PIPE_SPEC } = loadTs(path.join(ROOT, "A00_Common", "design", "design_structure_pipe.ts"));
|
||||
let count = 1;
|
||||
const form = createSpecForm(null, { count: (c) => (c.id === "soil_add" ? count : undefined) });
|
||||
form.write(PIPE_SPEC, {
|
||||
pipe_diameter_mm: 1000,
|
||||
soil_add_1_form: "콘크리트",
|
||||
soil_add_2_form: "돌망태",
|
||||
soil_add_2_height: 1.5,
|
||||
});
|
||||
const boxes = () =>
|
||||
form.root.children.filter((k) => k.dataset?.specContainer === "soil_add").length;
|
||||
const soilKeys = () =>
|
||||
Object.keys(form.read())
|
||||
.filter((k) => k.startsWith("soil_add_"))
|
||||
.sort();
|
||||
out.form = { shown1: boxes(), keys1: soilKeys() };
|
||||
count = 0; // 단 수 0 저장 — 옛 흙막이 값도 남음
|
||||
form.recount();
|
||||
out.form.shown0 = boxes();
|
||||
out.form.keys0 = soilKeys();
|
||||
count = 3;
|
||||
form.recount();
|
||||
out.form.shown3 = boxes();
|
||||
out.form.keys3 = soilKeys();
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,92 @@
|
||||
"""다단 흙막이 ↔ 흙막이 컨테이너 (PLAN 54-10).
|
||||
|
||||
짝: `B06_Section/B06_Section_UI_Page_Pipe_Options.ts`(컨테이너 값 → 다단 조정값 · 단별 구간값) ·
|
||||
`A00_Common/design/design_structure_form.ts`(개수보다 많은 컨테이너는 가리기만 · 값은 남김) ·
|
||||
`B06_Section/B06_Section_UI_Adjust_Dock.ts`(다단 벽 고르기 → 그 단의 흙막이 컨테이너).
|
||||
실제 TS 를 `helper_54_10_soil_tiers.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_54_10_soil_tiers.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def extras(updates: list[dict]) -> list[dict]:
|
||||
return [u for u in updates if u["role"].startswith("extra")]
|
||||
|
||||
|
||||
def test_컨테이너_형태_높이가_그_단_조정값으로(out) -> None:
|
||||
assert extras(out["filled"]["updates"]) == [
|
||||
{"role": "extra0", "h": 1.8, "m": "콘크리트"},
|
||||
{"role": "extra1", "h": 2.0, "m": "돌쌓기(메)"},
|
||||
]
|
||||
|
||||
|
||||
def test_지형이_못_세운_단은_그림에_안_감(out) -> None:
|
||||
roles = {u["role"] for u in out["filled"]["updates"]}
|
||||
assert "extra2" not in roles
|
||||
|
||||
|
||||
def test_높이_한도는_형태_규칙_그대로_폼에_되돌림(out) -> None:
|
||||
assert out["filled"]["overrides"] == [{"soil_add_2_height": 2}]
|
||||
assert out["toasts"] == ["흙막이 2 높이는 형태 한계로 2.0m까지만 적용됩니다."]
|
||||
|
||||
|
||||
def test_길이_전_후는_단별_구간값(out) -> None:
|
||||
assert out["filled"]["tiers"] == [{"key": "extra0", "lengthM": 8, "beforeM": 3, "afterM": 5}]
|
||||
|
||||
|
||||
def test_같은_값은_다시_그리지_않음(out) -> None:
|
||||
assert extras(out["same"]["updates"]) == []
|
||||
assert out["same"]["tiers"] == []
|
||||
|
||||
|
||||
def test_비운_칸은_자동_구간값은_그대로(out) -> None:
|
||||
assert extras(out["cleared"]["updates"]) == [
|
||||
{"role": "extra0", "h": None, "m": None},
|
||||
{"role": "extra1", "h": None, "m": None},
|
||||
]
|
||||
assert out["cleared"]["tiers"] == []
|
||||
|
||||
|
||||
def test_독립_기슭막이는_다단을_안_만짐(out) -> None:
|
||||
assert extras(out["hidden"]) == []
|
||||
|
||||
|
||||
def test_개수보다_많은_컨테이너는_가리고_값은_남김(out) -> None:
|
||||
form = out["form"]
|
||||
kept = ["soil_add_1_form", "soil_add_2_form", "soil_add_2_height"]
|
||||
assert (form["shown1"], form["keys1"]) == (1, kept)
|
||||
assert (form["shown0"], form["keys0"]) == (0, kept) # 단 수 0 저장에도 남음
|
||||
assert form["shown3"] == 3 and form["keys3"][:3] == kept
|
||||
|
||||
|
||||
def test_다단_벽_고르기는_흙막이_컨테이너로() -> None:
|
||||
dock = (ROOT / "B06_Section" / "B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8")
|
||||
assert 'fieldset[data-spec-container="soil_add"]' in dock
|
||||
assert "panelRoot.dataset.extraIndex" in dock
|
||||
panel = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Structure_Panel.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "root.dataset.extraIndex" in panel
|
||||
@@ -0,0 +1,107 @@
|
||||
/* 54-10 — B06 다단 흙막이 ↔ 배관 「흙막이」 컨테이너
|
||||
* 시작 화면: B06(#/b06-section) · 검증 프로젝트(sub5_검증) · 목록에 다단이 서는 관(기본 「배관 2」 12+18)
|
||||
* 부르는 법: orca eval --expression "window.__m5410={mode:'단계', n:0, item:'', value:''}; $(cat 이 파일)"
|
||||
* look → 흙막이 컨테이너 수 · 켜진 상자 · 옛 「추가 기슭막이」 칸 · 다단 벽 그림(형태 · 높이)
|
||||
* wall n → 횡단도 다단 n 번째(0 부터) 벽을 누름 → look
|
||||
* set n item value → 흙막이 n+1 컨테이너 item(form · height · length · before · after) 값 바꿈 → look
|
||||
* tier+ / tier- → 유출 「추가 기슭막이(단)」 + / -
|
||||
* 기대: wall n 뒤 켜진 상자 = n · 옛 칸 0 · set form → 벽 형태 · set height → 벽 H(한도 안)
|
||||
*/
|
||||
(async () => {
|
||||
const ARGS = window.__m5410 || { mode: "look" };
|
||||
const PIPE = ARGS.pipe || "배관 2";
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const until = async (fn, ms = 20000) => {
|
||||
for (let t = 0; t < ms; t += 200) {
|
||||
if (fn()) return true;
|
||||
await w(200);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const visible = (n) => n.offsetParent !== null && !n.closest("[hidden]");
|
||||
const spec = () =>
|
||||
[...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null);
|
||||
const soils = () => [...(spec()?.querySelectorAll('[data-spec-container="soil_add"]') ?? [])];
|
||||
const items = () => [...document.querySelectorAll(".b05-route__irregular-list li")];
|
||||
const station = () =>
|
||||
items()
|
||||
.find((l) => l.textContent.trim().endsWith(PIPE))
|
||||
?.textContent.trim()
|
||||
.split(PIPE)[0];
|
||||
const card = () =>
|
||||
[...document.querySelectorAll(".b06-cross-card")].find((c) =>
|
||||
c.textContent.startsWith(station()),
|
||||
);
|
||||
const pickPipe = async () => {
|
||||
await until(() => items().length > 0);
|
||||
const chosen = document.querySelector(".b05-route__irregular-list li.is-selected");
|
||||
if (spec() && chosen?.textContent.trim().endsWith(PIPE)) return;
|
||||
const li = items().find((l) => l.textContent.trim().endsWith(PIPE));
|
||||
(li.querySelector("button") ?? li).click();
|
||||
await until(() => spec(), 5000);
|
||||
await w(800);
|
||||
};
|
||||
const tierRow = () =>
|
||||
spec()
|
||||
?.querySelector('[data-spec-container="outlet"]')
|
||||
?.querySelector(".b06-structure-panel__struct--tier:not(.is-hidden)");
|
||||
const extraShapes = () =>
|
||||
[...(card()?.querySelectorAll(".b06-chart__culvert-revet") ?? [])]
|
||||
.map((s) => s.querySelector("title")?.textContent ?? "")
|
||||
.filter((t) => t.startsWith("추가"))
|
||||
.map((t) => /^(.*? H=[\d.]+m)/.exec(t)?.[1] ?? t.slice(0, 40));
|
||||
const look = () => {
|
||||
const boxes = soils();
|
||||
const rect = (b) => b.getBoundingClientRect();
|
||||
return {
|
||||
station: station(),
|
||||
soilCount: boxes.length,
|
||||
active: boxes
|
||||
.map((b, i) => (b.classList.contains("is-active") ? i : -1))
|
||||
.filter((i) => i >= 0),
|
||||
activeInView: boxes
|
||||
.filter((b) => b.classList.contains("is-active"))
|
||||
.map((b) => rect(b).top >= 0 && rect(b).bottom <= innerHeight),
|
||||
values: boxes.map((b) =>
|
||||
Object.fromEntries(
|
||||
[...b.querySelectorAll("[data-spec-item]")].map((n) => [
|
||||
n.dataset.specItem.split(".")[1],
|
||||
(n.querySelector("select, input, output") ?? {}).value ?? "",
|
||||
]),
|
||||
),
|
||||
),
|
||||
extraGroupShown: [...document.querySelectorAll("legend")].filter(
|
||||
(l) => visible(l) && l.textContent.startsWith("추가 기슭막이"),
|
||||
).length,
|
||||
tier: tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent ?? null,
|
||||
walls: extraShapes(),
|
||||
};
|
||||
};
|
||||
await pickPipe();
|
||||
const out = { mode: ARGS.mode, pipe: PIPE };
|
||||
if (ARGS.mode === "wall") {
|
||||
const hits = [...(card()?.querySelectorAll(".b06-chart__culvert-revet-hit") ?? [])].filter(
|
||||
(h) => h.querySelector("title")?.textContent.startsWith("추가"),
|
||||
);
|
||||
out.hits = hits.length;
|
||||
hits[ARGS.n]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await w(1500);
|
||||
}
|
||||
if (ARGS.mode === "set") {
|
||||
const field = soils()[ARGS.n]?.querySelector(`[data-spec-item="soil_add.${ARGS.item}"]`);
|
||||
const el = field?.querySelector("select, input");
|
||||
out.found = !!el;
|
||||
if (el) {
|
||||
el.value = ARGS.value;
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
await w(1500);
|
||||
}
|
||||
if (ARGS.mode === "tier+" || ARGS.mode === "tier-") {
|
||||
const btns = [...(tierRow()?.querySelectorAll("button") ?? [])];
|
||||
btns.find((b) => b.textContent === (ARGS.mode === "tier+" ? "+" : "-"))?.click();
|
||||
await w(1500);
|
||||
}
|
||||
Object.assign(out, look());
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
Reference in New Issue
Block a user