Merge commit 'a4c2c32e0' into sub_laptop_2

This commit is contained in:
2026-10-06 01:46:45 +09:00
9 changed files with 170 additions and 33 deletions
+15 -8
View File
@@ -5,6 +5,7 @@
* 컨테이너 셋(모두 필수): 본체 · 유입 구조 · 유출 구조. 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈.
* 저장 키 = pipe_points.json 옛 키 그대로(B06 횡단 · B07 · B08 이 읽는 자리):
* · 본체 = `body_width_m` · `body_height_m` 수 — 규격 칸은 저장 안 함(폭 · 높이로 되찾음 · `bodyOf`)
* · 사용자 입력에 두 칸 다 비면 `body_custom` = 「사용자 입력」 표시만
* · 유입 · 유출 구조 = `wing_in` · `wing_out`(없음 · 날개벽 · 흙막이 · 옛 「있음」 = 날개벽)
* — 엔진은 날개벽만 세움(`_wing_spec` ↔ `wingSpec` 짝)
* 표 02 에 없는 옛 날개벽 치수 칸은 화면에서 빼고 값은 남김(지난 정의 값).
@@ -14,6 +15,8 @@
import type { Instance, SpecItem, StructureSpec } from "./design_structure_spec";
export const BOX_CUSTOM = "사용자 입력";
/** 사용자 입력에 가로 · 세로 둘 다 빈 채 저장했다는 표시(폭 · 높이 수가 없을 때만 적음). */
export const BOX_CUSTOM_KEY = "body_custom";
/** 본체 규격 → [가로, 세로](m) — 실무 관측 규격(2026-08-17 사용자 확정). */
const BOX_PRESETS: Record<string, readonly [number, number]> = {
"2.0×2.0": [2, 2],
@@ -97,21 +100,25 @@ export function boxOptionsIn(
return out;
}
/** 저장 폭 · 높이 → 본체 칸 값 — 규격과 맞으면 그 규격 · 아니면 사용자 입력 · 없으면 빈 값. */
/** 저장 폭 · 높이 → 본체 칸 값 — 규격과 맞으면 그 규격 · 손 값이 하나라도 있거나 사용자 입력 표시면
* 사용자 입력(빈 칸은 빈 채) · 아니면 빈 값. */
export function bodyOf(options: Record<string, string | number>): Instance {
const width = Number(options.body_width_m);
const height = Number(options.body_height_m);
if (!(width > 0 && height > 0)) return { size: "" };
const preset = Object.entries(BOX_PRESETS).find(
([, [w, h]]) => Math.abs(w - width) < 0.05 && Math.abs(h - height) < 0.05,
);
return preset
? { size: preset[0] }
: { size: BOX_CUSTOM, width: String(width), height: String(height) };
if (preset) return { size: preset[0] };
if (!(width > 0) && !(height > 0) && options[BOX_CUSTOM_KEY] !== BOX_CUSTOM) return { size: "" };
const side = (n: number): string => (n > 0 ? String(n) : "");
return { size: BOX_CUSTOM, width: side(width), height: side(height) };
}
/** 본체 칸 값 → 저장 폭 · 높이 — 규격이면 규격 수(사용자 입력은 명세 키가 적음). */
export function bodyOptions(body: Instance | undefined): Record<string, number> {
/** 본체 칸 값 → 저장 폭 · 높이 — 규격이면 규격 수(사용자 입력은 명세 키가 적음) · 사용자 입력에 두 칸 다
* 비면 표시 키만(다시 열 때 「사용자 입력」). */
export function bodyOptions(body: Instance | undefined): Record<string, string | number> {
const preset = BOX_PRESETS[body?.size ?? ""];
return preset ? { body_width_m: preset[0], body_height_m: preset[1] } : {};
if (preset) return { body_width_m: preset[0], body_height_m: preset[1] };
const blank = !(body?.width ?? "").trim() && !(body?.height ?? "").trim();
return body?.size === BOX_CUSTOM && blank ? { [BOX_CUSTOM_KEY]: BOX_CUSTOM } : {};
}
@@ -35,7 +35,7 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
...revetKeys("outlet"),
],
// BOX = 표 02 명세 키(PLAN 52-2) — 옛 날개벽 치수는 폼에서 빠져 이어 붙임(지난 정의 값)
box_culvert: ["body_width_m", "body_height_m", "wing_in", "wing_out"],
box_culvert: ["body_width_m", "body_height_m", "body_custom", "wing_in", "wing_out"],
// 물넘이 포장 = 표 03 명세 키(PLAN 52-3 · `design_structure_ford_pavement`) — 옛 월류 · 경사 · 두께 · 길이는
// 폼에서 빠져 이어 붙임 · 도수로/돌붙임 키는 `FORM_OPTION_PREFIXES`
ford_pavement: ["ford_slope_deg"],
@@ -313,15 +313,14 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
async function loadStructures(): Promise<void> {
try {
// 레지스트리는 **한 번만** 받고 쓰는 자리에 따라 나눈다(2026-09-07).
// · 추가 메뉴 — 켜진 종류만(B05 는 종전대로 여섯 군).
// · 알약 레인·군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은 레지스트리에서
// 꺼져 있는데, 켜진 것만 주면 이름·색·군을 못 찾아 종단에 「?」 회색 알약으로 뜬다.
// · 고르기 목록 — 켜진 종류만(B05 는 종전대로 여섯 군).
// · 목록 이름 · 폼 · 알약 레인 · 군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은
// 레지스트리에서 꺼져 있는데, 켜진 것만 주면 이름을 못 찾아 영문 키 · 「?」 알약으로 뜬다.
const [allTypes, stored] = await Promise.all([
fetchStructureTypes(true),
fetchStructures(deps.projectId),
]);
const menuTypes = allTypes.filter((type) => type.enabled);
deps.panel().structures.setTypes(menuTypes);
deps.panel().structures.setTypes(allTypes, (type) => type.enabled);
deps.profilePanel().setStructureTypes(allTypes);
// A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다.
structureTypeMap = new Map(
+8 -10
View File
@@ -53,9 +53,9 @@ export { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel_Types";
export type { PipeFacilityItem, StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types";
import {
DEFAULT_INTERVAL_LENGTH_M,
GROUP_LABELS,
groupOption,
groupOptions,
hasComputedRange,
pickOption,
readBeforeOption,
readLengthOption,
type PipeFacilityItem,
@@ -165,6 +165,7 @@ export function createStructuresSection(
}
let types: StructureType[] = [];
let canPick: (type: StructureType) => boolean = () => true;
// 배열 자체를 바꾸지 않는다(조각들이 참조로 들고 있음) — 내용만 갈아끼운다.
const structures: StructureInstance[] = [];
let pipeFacilities: PipeFacilityItem[] = [];
@@ -348,7 +349,7 @@ export function createStructuresSection(
const candidates = group ? types.filter((t) => t.group === group && pickable(t)) : [];
typeSelect.replaceChildren(
new Option("— 선택 —", ""),
...candidates.map((type) => new Option(type.name, type.type_id)),
...candidates.map((type) => pickOption(type.name, type.type_id, !canPick(type))),
);
typeSelect.value =
keepTypeId && candidates.some((type) => type.type_id === keepTypeId) ? keepTypeId : "";
@@ -539,14 +540,11 @@ export function createStructuresSection(
facility: facilityOptions,
listRoot: list,
hasSelection: () => editingId !== null || selectedPipeChainage !== null,
setTypes(next) {
setTypes(next, pick = () => true) {
types = next;
const groups = new Map(next.map((t) => [t.group, t.group_name] as const));
// 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다. 이름은 정의(48) 먼저.
groupSelect.replaceChildren(
new Option("— 선택 —", ""),
...[...groups].map(([g, name]) => groupOption(g, name ?? GROUP_LABELS[g] ?? g)),
);
canPick = pick;
// 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다.
groupSelect.replaceChildren(new Option("— 선택 —", ""), ...groupOptions(next, pick));
groupSelect.value = "";
syncTypeOptions();
},
@@ -38,14 +38,29 @@ export const GROUP_LABELS: Record<string, string> = {
* 옛 값 · 도면 · 집계는 그대로이고 데이터도 지우지 않는다. */
export const HIDDEN_PICK_GROUPS: ReadonlySet<string> = new Set(["B", "D"]);
/** 군 드롭다운 항목 — 뺄 군은 가림(옛 항목을 열 때는 값으로 그대로 고를 수 있음). */
export function groupOption(group: string, name: string): HTMLOptionElement {
const option = new Option(name, group);
option.hidden = HIDDEN_PICK_GROUPS.has(group);
if (option.hidden) option.dataset.pickHidden = ""; // 정의 폼의 「가림 풀기」가 건드리지 않게
/** 고르기 목록 항목 — 가릴 항목은 가림(옛 항목을 열 때는 값으로 그대로 고를 수 있음). */
export function pickOption(text: string, value: string, hidden: boolean): HTMLOptionElement {
const option = new Option(text, value);
option.hidden = hidden;
if (hidden) option.dataset.pickHidden = ""; // 정의 폼의 「가림 풀기」가 건드리지 않게
return option;
}
/** 군 드롭다운 항목들 — 이름은 정의(48) 먼저 · 뺄 군과 고를 종류가 없는 군은 가림. */
export function groupOptions(
types: StructureType[],
pick: (type: StructureType) => boolean,
): HTMLOptionElement[] {
const groups = new Map(types.map((t) => [t.group, t.group_name] as const));
return [...groups].map(([g, name]) =>
pickOption(
name ?? GROUP_LABELS[g] ?? g,
g,
HIDDEN_PICK_GROUPS.has(g) || !types.some((t) => t.group === g && pick(t)),
),
);
}
/** 구간형 신규 추가 시 기본 구간 길이(m). 사용자가 종점을 바로 고칠 수 있게 짧게 잡는다. */
export const DEFAULT_INTERVAL_LENGTH_M = 15;
@@ -95,8 +110,9 @@ export interface StructuresSection {
/** 배치된 구조물 목록(<ul>) — 섹션 본문이 아니라 사이드 하단 고정 영역에 붙인다
* (2026-08-18 사용자 지시: 폼 길이에 밀려 목록이 화면 밖으로 나가던 문제). */
listRoot: HTMLElement;
/** 서버에서 받은 타입 목록을 채운다(최초 1회). */
setTypes: (types: StructureType[]) => void;
/** 서버에서 받은 타입 목록을 채운다(최초 1회). 이름·폼은 넘긴 종류 전부로 풀고,
* 고르기 목록엔 `pick` 이 고른 종류만 보임(빼면 전부). */
setTypes: (types: StructureType[], pick?: (type: StructureType) => boolean) => void;
/** 서버 정본으로 목록을 교체한다(진입·복원·저장 후). */
setStructures: (structures: StructureInstance[]) => void;
getStructures: () => StructureInstance[];
@@ -25,6 +25,8 @@ import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel";
/** 날개벽 한 벌의 저장 키 — 세월교·BOX암거가 같은 옵션 이름을 쓴다(`wing_in*`/`wing_out*`). */
type WingPatch = Partial<{
installed: boolean;
/** 폼이 낸 설치 글(날개벽 · 흙막이 …) — 있으면 저장에 그 글 그대로(있음/없음으로 안 바꿈). */
text: string;
height_m: number;
length_m: number;
angle_deg: number;
@@ -34,7 +36,8 @@ type WingPatch = Partial<{
function wingOptions(role: "inlet" | "outlet", patch: WingPatch): Record<string, number | string> {
const prefix = role === "inlet" ? "wing_in" : "wing_out";
const options: Record<string, number | string> = {};
if (patch.installed !== undefined) options[prefix] = patch.installed ? "있음" : "없음";
if (patch.installed !== undefined)
options[prefix] = patch.text ?? (patch.installed ? "있음" : "없음");
if (patch.height_m !== undefined) options[`${prefix}_height_m`] = patch.height_m;
if (patch.length_m !== undefined) options[`${prefix}_length_m`] = patch.length_m;
if (patch.angle_deg !== undefined) options[`${prefix}_angle_deg`] = patch.angle_deg;
@@ -82,7 +85,10 @@ function wingPatchFrom(patch: Record<string, number | string>, prefix: string):
};
const wing: WingPatch = {};
// 설치 값 글(있음 · 없음 · BOX 날개벽 · 흙막이) — 백엔드 `_wing_spec` 과 같은 판정.
if (patch[prefix] !== undefined) wing.installed = wingInstalled(patch[prefix]);
if (patch[prefix] !== undefined) {
wing.installed = wingInstalled(patch[prefix]);
wing.text = String(patch[prefix]);
}
const heightM = num(`${prefix}_height_m`);
if (heightM !== undefined) wing.height_m = heightM;
const lengthM = num(`${prefix}_length_m`);
@@ -55,7 +55,15 @@ const out = {
st.body[0].height = "1.8";
}),
blank: roundTrip({}),
// 52-19 — 사용자 입력에서 한 칸 · 두 칸 비우고 저장 → 다시 열기
oneBlank: roundTrip({ body_height_m: 2.5 }),
bothBlank: roundTrip({ body_width_m: 2, body_height_m: 2 }, (st) => {
st.body[0].size = "사용자 입력";
st.body[0].width = "";
st.body[0].height = "";
}),
keys: s.specKeys(SPEC),
retired: SPEC.retired,
};
out.bothBlankAgain = roundTrip(out.bothBlank.out);
process.stdout.write(JSON.stringify(out));
@@ -76,6 +76,24 @@ def test_빈_값은_빈_채로(out):
assert out["blank"]["state"]["body"][0]["size"] == ""
def test_사용자_입력_빈_칸은_다시_열어도_사용자_입력(out):
# 52-19 — 가로만 비움: 세로 손 값이 남아 사용자 입력 + 빈 가로
assert out["oneBlank"]["state"]["body"][0] == {
"size": "사용자 입력",
"width": "",
"height": "2.5",
}
assert out["oneBlank"]["out"] == {"body_height_m": 2.5}
# 둘 다 비움: 표시 키만 적고 다시 열면 사용자 입력 + 빈 칸 둘
assert out["bothBlank"]["out"] == {"body_custom": "사용자 입력"}
assert out["bothBlankAgain"]["state"]["body"][0] == {
"size": "사용자 입력",
"width": "",
"height": "",
}
assert out["bothBlankAgain"]["out"] == {"body_custom": "사용자 입력"}
def test_정의_파일에_이름_표_그룹_선택지가_올라_있음():
names = _node("names")
with open(
@@ -0,0 +1,85 @@
/* 52-19 — BOX암거 본체 「사용자 입력」 빈 칸 저장 → 다시 열기
* 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트 · 목록에 「BOX암거」(52-2 edit 뒤 2.4 × 1.8 · 흙막이)
* 부르는 법: `window.__m5219 = "단계";` 를 붙여 — 단계 사이 orca reload
* one → 가로 비우고 [저장] (reload) view → 사용자 입력 · 가로 빈 칸 · 세로 1.8
* both → 세로도 비우고 [저장] (reload) view → 사용자 입력 · 빈 칸 둘 · 파일 body_custom
* fill → 2.2 × 1.6 [저장] → 파일에 body_custom 없음
* 기대: 유입 · 유출 구조 글(흙막이 · 없음) 그대로 · 되돌림은 52-2 restore
*/
(async () => {
const MODE = window.__m5219;
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 pid = localStorage.getItem("frd_current_project_id");
const api = await import(
performance
.getEntriesByType("resource")
.map((e) => e.name)
.filter((n) => n.includes("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts"))
.pop()
);
const spec = () =>
[...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null);
const box = (c) => spec()?.querySelector(`[data-spec-container="${c}"]`);
const ctl = (c, i) =>
box(c)?.querySelector(`[data-spec-item="${c}.${i}"]`)?.querySelector("select, input");
const set = async (c, i, v) => {
const el = ctl(c, i);
el.value = v;
el.dispatchEvent(new Event("change", { bubbles: true }));
await w(200);
};
await until(() =>
[...document.querySelectorAll(".b05-route__irregular-list li")].some((l) =>
/BOX암거$/.test(l.textContent.trim()),
),
);
const li = [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) =>
/BOX암거$/.test(l.textContent.trim()),
);
(li.querySelector("button") ?? li).click();
await w(800);
if (!box("body")) (li.querySelector("button") ?? li).click();
await until(() => !!box("body"));
await w(500);
const read = () => ({
size: ctl("body", "size")?.value,
width: ctl("body", "width")?.value ?? null,
height: ctl("body", "height")?.value ?? null,
});
const file = async () =>
(await api.fetchDetailPipePoints(pid)).pipe_points.find((p) => p.facility === "box_culvert")
?.options;
const save = async () => {
document.querySelector('[data-dock-action="save"]').click();
await w(500);
return until(() => !document.querySelector(".ui-overlay.is-active"), 60000);
};
const wings = () => ["inlet", "outlet"].map((c) => ctl(c, "structure")?.value);
const out = { mode: MODE, reopened: read(), wings: wings() };
if (MODE === "one") {
await set("body", "width", "");
out.saved = await save();
out.file = await file();
}
if (MODE === "both") {
await set("body", "height", "");
out.saved = await save();
out.file = await file();
}
if (MODE === "fill") {
await set("body", "width", "2.2");
await set("body", "height", "1.6");
out.saved = await save();
out.file = await file();
}
out.wingsAfter = wings();
out.after = read();
return JSON.stringify(out);
})();