Merge remote-tracking branch 'origin/dev' into sub_laptop_7

This commit is contained in:
2026-10-06 14:56:31 +09:00
10 changed files with 134 additions and 70 deletions
+21 -8
View File
@@ -52,8 +52,6 @@ export interface SpecLinks {
export interface SpecFormHooks {
/** 자동 항목 값 글(빈 글 = 「—」). */
auto?: (c: SpecContainer, item: SpecItem, state: SpecState) => string;
/** 숫자 칸 빈 칸 안내. */
hint?: (c: SpecContainer, item: SpecItem, state: SpecState) => string;
onChange?: (c: SpecContainer, item: SpecItem) => void;
/** 선택 컨테이너 개수를 밖에서 정함(54-1 배관 흙막이 = 추가 기슭막이(단)) — 수면 그만큼 늘 보임 ·
* [추가] · [빼기] 없음 · 줄면 끝에서부터 가림(값은 남음 · 54-10) · null = 모름(저장된 것 그대로) ·
@@ -61,7 +59,7 @@ export interface SpecFormHooks {
count?: (c: SpecContainer) => number | null | undefined;
/** 컨테이너 칸 끝에 덧붙이는 칸(54-27 배관 유출 「추가 흙막이(단)」 · 값은 저장 옵션 밖). */
fields?: (c: SpecContainer, values: Record<string, string>) => HTMLElement[];
/** 그림이 쓰는 값(54-13) — 고르기 칸이 비었거나 자동 규칙일 때 그 값 · 꼬리(「기본」 · 「자동」) ·
/** 그림이 쓰는 값(54-13) — 자동 규칙일 때 그 값 · 꼬리(「자동」) ·
* null = 없음. 보여 주기만(값 · 저장은 사용자가 고를 때만). `values` = 보이는 값(위 칸 반영). */
shown?: (
c: SpecContainer,
@@ -79,6 +77,8 @@ export interface SpecForm {
options: Record<string, string | number>,
names?: SpecNames,
groupNames?: Record<string, string>,
/** 정의 기본값(저장 키 → 값) — 필수 상자 빈 고르기 칸이 받는 값(54-43). */
defaults?: Record<string, string | number>,
) => void;
read: () => Record<string, string | number>;
state: () => SpecState;
@@ -100,6 +100,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
let state: SpecState = {};
let names: SpecNames = {};
let groupNames: Record<string, string> = {};
let defaults: Record<string, string | number> = {};
const recent = new Map<Instance, SplitRole[]>();
const slots = new Map<string, HTMLElement>();
const rows: Array<{ key: string; set: (item: SummaryItem | null) => void }> = [];
@@ -128,9 +129,10 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
const drawn = choices.find((ch) => ch.value === now);
// 그림이 쓰는 값은 빈 보기 자리에 꼬리를 달아 보임 — 고른 값과 구별(54-13)
const name = drawn ? choiceLabel(c, item, drawn, names) : now;
const blank = !tag ? "선택 안 함" : name ? `${name} (${tag})` : tag;
const blank = !tag ? "" : name ? `${name} (${tag})` : tag;
// 필수 상자 = 빈 보기 없음(54-43) · 선택 상자 빈 보기 = 빈 글
select.replaceChildren(
new Option(blank, ""),
...(c.required && !tag ? [] : [new Option(blank, "")]),
...choices.map((ch) => new Option(choiceLabel(c, item, ch, names), ch.value)),
);
// 선택지 밖 옛 값은 그 값만 남겨 보임(지운 값을 몰래 바꾸지 않음)
@@ -145,8 +147,8 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
input.value = values[item.id] ?? "";
return input;
}
// 기본값은 새 상자 칸에 실제 값으로만(54-39) — 빈 칸 「(기본)」 안내 없음
const input = numberInput(String(item.step ?? 0.1), "0", hooks.hint?.(c, item, state) ?? "");
// 빈 칸 = 그냥 빈 칸(54-43 · 자리표시 글 없음)
const input = numberInput(String(item.step ?? 0.1), "0");
if (item.kind === "auto") {
// 보여 주는 칸(54-1) — 손 입력 칸 아님
const shown = document.createElement("output");
@@ -203,6 +205,16 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
const box = group(c.required ? title : `${title} ${index + 1}`);
box.root.dataset.specContainer = c.id;
box.root.dataset.specIndex = String(index); // 칸을 고친 뒤 다시 그려도 몇째 상자인지(54-30)
// 필수 상자 빈 고르기 칸 = 정의 기본값(없으면 첫 보기)을 실제 값으로 — 저장도 그 값(54-43)
if (c.required)
for (const item of c.items) {
if (item.kind !== "select" || values[item.id] || (item.by && !branchOf(item, values)))
continue;
const choices = choicesOf(item, values);
const key = branchOf(item, values)?.key;
const wanted = String((key ? defaults[key] : undefined) ?? item.default ?? "");
values[item.id] = (choices.find((ch) => ch.value === wanted) ?? choices[0])?.value ?? "";
}
// 보이는 값 = 고른 값 + 그림이 쓰는 값(54-13) · 위 칸이 다른 갈래를 보이면 아래 칸은 빈 채로
const view: Instance = { ...values };
const tags = new Map<string, string>();
@@ -320,8 +332,9 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
return {
root,
write(next, options, nextNames = {}, nextGroups = {}) {
write(next, options, nextNames = {}, nextGroups = {}, nextDefaults = {}) {
spec = next;
defaults = nextDefaults;
names = nextNames;
groupNames = nextGroups;
state = readState(next, options);
+25 -15
View File
@@ -41,13 +41,13 @@ import {
type InletStructureKind,
labeled,
numberInput,
optionalSelect,
OUTLET_REVET_KEYS,
stepper,
WING_IN_KEYS,
WING_OUT_KEYS,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
import { revetChoiceRow } from "./B05_Profile_UI_Drainage_Revet_Choice";
import { optionDefaults } from "./B05_Profile_UI_Structures_Fields";
import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form";
import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe";
import {
@@ -57,7 +57,7 @@ import {
boxOptionsIn,
} from "../A00_Common/design/design_structure_box";
import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structure_ford";
import { fordHeightHint, fordHeightM } from "../A00_Common/design/design_structure_ford_calc";
import { fordHeightM } from "../A00_Common/design/design_structure_ford_calc";
import {
pipeDrawn,
pipeExtraCount,
@@ -216,8 +216,8 @@ export interface FacilityOptionsForm {
definition?: {
spec_names?: Record<string, string>;
groups?: Array<{ id: string; name: string }>;
/** 옵션 정의 기본값 — 빈 고르기 칸이 그림 값을 보임(54-13). */
options?: Array<{ key: string; default: unknown }>;
/** 옵션 정의 기본값 — 필수 상자 빈 고르기 칸이 받는 값(54-43). */
options?: Array<{ key: string; default: string | number | null }>;
} | null,
) => void;
/** 배관 명세 컨테이너 「구조물 조합」 잇기 자리 — 폼이 패널 그룹 값에 이음(PLAN 52-1). */
@@ -289,7 +289,7 @@ export function createFacilityOptionsForm(
return drawnMemo;
};
// 유출 「추가 흙막이(단)」(54-27) — 값 = 횡단 조정 단 수(같은 저장 자리) · 흙막이 상자 수가 따라감
const tiersInput = numberInput("1", "0", "횡단 설계 뒤");
const tiersInput = numberInput("1", "0");
tiersInput.max = "9";
const tiersRow = labeled("추가 흙막이(단)", stepper(tiersInput, 1, 0));
tiersRow.dataset.specItem = "outlet.tiers";
@@ -378,10 +378,6 @@ export function createFacilityOptionsForm(
const fordNeed = (): number | null =>
fordHeightM(fordFlow, Number(fordForm.state().ford?.[0]?.width ?? ""));
const fordForm = createSpecForm(specLinks, {
hint: (c, item, state) =>
c.id === "ford" && item.id === "height"
? fordHeightHint(fordFlow, Number(state.ford?.[0]?.width ?? ""))
: "",
onChange: (c, item) => {
const need = c.id === "ford" ? fordNeed() : null;
const height = fordForm.state().ford?.[0]?.height ?? "";
@@ -406,7 +402,9 @@ export function createFacilityOptionsForm(
// 나눈다(2026-08-30 사용자). 설치 측이 한쪽이면 그 칸만 뜬다.
// 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만
// 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다.
const revetSide = optionalSelect("— 안 정함 —", ["양쪽", "좌", "우"]);
// 설치 측 = 필수 칸 — 빈 보기 없음(54-43 · 빈 옛 값 = 양쪽)
const revetSide = document.createElement("select");
revetSide.replaceChildren(...["양쪽", "좌", "우"].map((v) => new Option(v, v)));
const revetTopRow = grid(labeled("설치 측", revetSide, "side"));
const revetInlet = createRevetSideGroup(INLET_REVET_KEYS);
const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS);
@@ -582,6 +580,8 @@ export function createFacilityOptionsForm(
// 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7).
const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name]));
const names = definition?.spec_names ?? {};
// 필수 상자 빈 고르기 칸이 받는 정의 기본값(54-43) — 옛 키는 열 때와 같은 길로 바꿈
const defaults = optionDefaults(definition?.options ?? []);
if (facility === "pipe") {
// 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1)
const { soil_add: _old, ...pipeNames } = names;
@@ -591,21 +591,31 @@ export function createFacilityOptionsForm(
!["revet", "I", "L", "U"].includes(String(kind ?? ""))
? { structure: kind }
: null;
pipeForm.write(PIPE_SPEC, options, pipeNames, groups);
pipeForm.write(PIPE_SPEC, options, pipeNames, groups, defaults);
}
if (facility === "box_culvert") {
// 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 · 옛 설치 「있음」 = 날개벽
boxForm.write(BOX_SPEC, boxOptionsIn(options), names, groups);
boxForm.write(BOX_SPEC, boxOptionsIn(options), names, groups, boxOptionsIn(defaults));
for (const [id, value] of Object.entries(bodyOf(options)))
boxForm.setValue("body", id, value);
}
if (facility === "ford_bridge")
fordForm.write(FORD_SPEC, withLegacyGuards(options), names, groups);
if (facility === "ford_pavement") paveForm.write(FORD_PAVEMENT_SPEC, options, names, groups);
fordForm.write(
FORD_SPEC,
withLegacyGuards(options),
names,
groups,
withLegacyGuards(defaults),
);
if (facility === "ford_pavement")
paveForm.write(FORD_PAVEMENT_SPEC, options, names, groups, defaults);
wingInFields.write(options);
wingOutFields.write(options);
revetSide.value = text("side");
revetSide.value = text("side") || "양쪽";
const spread = legacyRevetOptions(options);
// 빈 형태 = 정의 기본값(54-43 · 필수 칸)
for (const key of [INLET_REVET_KEYS.form, OUTLET_REVET_KEYS.form])
if (!spread[key] && defaults.form) spread[key] = defaults.form;
revetInlet.fields.write(spread);
revetOutlet.fields.write(spread);
syncVisibility();
@@ -159,16 +159,13 @@ export function putNumber(target: Record<string, string | number>, key: string,
if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1));
}
/** 선택지 셀렉트 — **맨 앞에 빈 보기(「안 정함」)** 를 둠. 첫 보기를 미리 고르지 않음
/** 선택지 셀렉트 — **맨 앞에 빈 보기(빈 글 · 54-43)** 를 둠. 첫 보기를 미리 고르지 않음
* (2026-09-14 브레인 판정 · 옛 2026-08-17 「빈 항목 두지 않음」 걷음) — 고른 적 없는 첫 값이 저장돼
* 확정으로 굳지 않게. 기본값이 있는 칸은 부르는 쪽이 값을 넣음(그것은 따로 차례). */
export function optionalSelect(
blankLabel: string,
choices: ReadonlyArray<string>,
): HTMLSelectElement {
export function optionalSelect(choices: ReadonlyArray<string>): HTMLSelectElement {
const element = document.createElement("select");
element.replaceChildren(
new Option(blankLabel, ""),
new Option("", ""),
...choices.map((choice) => new Option(choice, choice)),
);
return element;
@@ -211,13 +208,6 @@ export interface RevetmentKeys {
after?: string;
}
/** 높이 칸 안내 — 관 벽은 비우면 관경 기준 최소 높이(엔진 `pipe_wall_min_height_m` · 함수로 받아
* 볼 때마다 셈) · 빈 글이면 「설계자 입력」. 형태 · 길이 · 전후 기본값은 등록부가 시설이 생길 때
* 칸에 실제 값으로 넣음(PLAN 46-7 · 제안값 단추 없음). */
export interface RevetmentDefaults {
height: string | (() => string);
}
export const INLET_REVET_KEYS: RevetmentKeys = {
form: "inlet_revet_form",
length: "inlet_revet_length_m",
@@ -249,7 +239,7 @@ export function createRevetSideGroup(keys: RevetmentKeys): RevetSideGroup {
const kind = document.createElement("select");
kind.replaceChildren(new Option("기슭막이", "기슭막이"));
// 높이는 기본값 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정).
const fields = createRevetmentFields(keys, { height: "" });
const fields = createRevetmentFields(keys);
// 치수 근거 글(사방기술교본)은 [?] 설명서 `구조물_기슭막이.md` 로 옮김(54-2)
const slot = document.createElement("div");
slot.className = "b05-structure__adjust-slot";
@@ -282,24 +272,13 @@ export interface RevetmentFields {
* (2026-08-17 사용자 지시 9·11). 재질과 표면처리는 "형태" 한 축(지시 1). 바닥
* 보호공(돌붙임) 한 벌은 뺐다 — 기슭막이는 사면 공정이라 그 아래 돌붙임이 붙을
* 자리가 아니다(2026-08-19 사용자 지시 1). */
export function createRevetmentFields(
keys: RevetmentKeys,
defaults: RevetmentDefaults,
): RevetmentFields {
const wallFloor = (): string =>
typeof defaults.height === "function" ? defaults.height() : defaults.height;
export function createRevetmentFields(keys: RevetmentKeys): RevetmentFields {
// 형태 = 필수 칸 — 빈 보기 없음(54-43 · 값은 부르는 쪽이 정의 기본값으로 채움)
const form = document.createElement("select");
form.replaceChildren(
new Option("— 안 정함 —", ""),
...REVET_FORMS.map((value) => new Option(value, value)),
);
form.replaceChildren(...REVET_FORMS.map((value) => new Option(value, value)));
const length = keys.length ? numberInput("0.1", "0") : null;
// 관 벽은 비우면 관경 기준 최소 높이로 섬(엔진) · 독립 기슭막이 높이는 「설계자 입력」.
const heightHint = (): string =>
wallFloor() ? `비우면 관경 기준 ${wallFloor()}` : "설계자 입력";
const height = numberInput("0.1", "0", heightHint());
// 관 벽 기준 높이는 관경을 따라감 — 칸을 볼 때마다 다시 셈.
height.addEventListener("focus", () => (height.placeholder = heightHint()));
// 빈 칸 = 그냥 빈 칸(54-43 · 자리표시 글 없음)
const height = numberInput("0.1", "0");
// 기준측점 전/후(2026-08-23 사용자) — 구조물 패널(D군)과 같은 연동:
// 전 + 후 = 길이, 나중에 고친 쪽이 살아남고 반대쪽이 재배분된다.
const before = keys.before && length ? numberInput("0.1", "0") : null;
@@ -342,9 +321,9 @@ export function createRevetmentFields(
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
form.value = text(keys.form);
if (form.selectedIndex < 0) form.selectedIndex = 0;
if (length && keys.length) length.value = text(keys.length);
height.value = text(keys.height);
height.placeholder = heightHint();
if (before && keys.before) before.value = text(keys.before);
if (after && keys.after) after.value = text(keys.after);
syncSplit("length");
@@ -476,7 +455,7 @@ interface WingFields {
* 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴).
* 기본 제원(있음·1·2·45, 사용자 확정)은 등록부 기본값 — 시설이 생길 때 칸에 실제 값(PLAN 46-7). */
export function createWingFields(title: string, keys: WingKeys): WingFields {
const install = optionalSelect("— 안 정함 —", ["있음", "없음"]);
const install = optionalSelect(["있음", "없음"]);
const height = numberInput("0.1", "0");
const length = numberInput("0.1", "0");
const angle = numberInput("1", "0");
@@ -54,7 +54,8 @@ interface OptionShape {
/**
* 등록부 옵션 입력 한 칸 — **받은 값만 칸에 넣음**. 새 구조물은 부르는 쪽이 등록부 기본값을 값으로
* 실어 줌(PLAN 46-7 · 제안값 단추 없음) · 옛 기록의 빈 칸은 빈 칸 그대로(미확정).
* 고르기 칸은 늘 빈 보기를 둠 — 첫 보기를 미리 고르지 않음.
* 필수 · 기본값 있는 고르기 칸 = 빈 보기 없음(54-43 · 빈 옛 값은 기본값 · 없으면 첫 보기) ·
* 그 밖 고르기 칸 = 빈 글 빈 보기.
*/
export function optionControl(
option: OptionShape,
@@ -66,15 +67,16 @@ export function optionControl(
// 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻.
const tip = option.not_in_table ?? option.empty_means ?? "";
if (option.input === "select") {
// 빈 보기 = 값 없음(54-39) — 기본값은 새 구조물 칸에 실제 값으로만(「(기본)」 꼬리 없음)
const blank = option.required ? "— 선택 —" : "선택 안 함";
const fallback = option.default === null || option.default === "" ? "" : String(option.default);
const must = !!option.required || fallback !== "";
// 정의에서 뺀 선택지(48)를 고른 옛 값 — 그 값만 남겨 보임(값을 잃지 않음)
const kept = value && !option.choices.includes(value) ? [value] : [];
const element = select([
["", blank],
...(must ? [] : [["", ""] as [string, string]]),
...[...option.choices, ...kept].map((c) => [c, label(c)] as [string, string]),
]);
element.value = value;
element.value = value || fallback;
if (element.selectedIndex < 0) element.selectedIndex = 0;
if (tip) element.title = tip;
return element;
}
@@ -82,7 +84,6 @@ export function optionControl(
option.input === "number" ? numberInput("0.1", "0") : document.createElement("input");
if (option.input !== "number") element.type = "text";
element.value = value;
element.placeholder = option.required ? "필수 입력" : "";
if (tip) element.title = tip;
const limit = option.warn_above;
if (typeof limit === "number") {
@@ -239,9 +239,7 @@ export function createStructuresSection(
root.dataset.helpTopic = type ? `구조물_${type.name}` : "";
// 정의가 기준 측점을 끈 종류(48-3) — 기준 측점이 곧 위치인 종류(점형 · 범위 계산)는 늘 보임
anchorFields.wrap.hidden = isInterval && !showsAnchor(type!);
anchorFields.wrap.querySelector("span")!.textContent = isInterval
? "기준 측점 (비우면 시작)"
: "기준 측점";
anchorFields.wrap.querySelector("span")!.textContent = "기준 측점"; // 빈 칸 안내 없음(54-43)
syncRangeDisplay();
}
@@ -250,14 +250,16 @@ function syncChoices(
choices: string[],
label: (choice: string) => string,
): void {
const now = [...element.children].slice(1).map((o) => (o as HTMLOptionElement).value);
// 빈 보기는 선택 칸에만(54-43 · 필수 칸은 없음)
const blank = [...element.options].filter((o) => o.value === "").slice(0, 1);
const now = [...element.options].filter((o) => o.value !== "").map((o) => o.value);
const keep = element.value && !choices.includes(element.value) ? [element.value] : [];
const want = [...choices, ...keep];
if (now.length === want.length && now.every((v, i) => v === want[i])) return;
const value = element.value;
const blank = element.children[0];
element.replaceChildren(blank, ...want.map((c) => new Option(label(c), c)));
element.replaceChildren(...blank, ...want.map((c) => new Option(label(c), c)));
element.value = value;
if (element.selectedIndex < 0) element.selectedIndex = 0;
}
/** 「지난 정의 값」 상자(48-3) — 지금 정의에 없는 키의 값을 읽기만 칸으로 보임(오류 없음 · 저장은
@@ -16,6 +16,7 @@ import {
} from "../A00_Common/design/design_structure_form";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
import { specKeys, type StructureSpec } from "../A00_Common/design/design_structure_spec";
import { optionDefaults } from "./B05_Profile_UI_Structures_Fields";
const forms = new WeakMap<HTMLElement, SpecForm>();
@@ -63,7 +64,7 @@ export function paintStructureSpec(
form.root.hidden = !spec;
if (!spec) return;
const groups = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name]));
form.write(spec, values, type?.spec_names ?? {}, groups);
form.write(spec, values, type?.spec_names ?? {}, groups, optionDefaults(type?.options ?? []));
}
/** 명세 폼 값(저장 키 그대로) — 명세 없는 종류면 빈 묶음. */
@@ -289,3 +289,9 @@ B05에서 3D 영역의 상단 버튼들 중 원근으로, 직교로 버튼의
(10-6 사용자)
빈칸은 그냥 빈칸이야. 별도의 안내는 필요없어. 기본 값 XX 라 흐린 글씨로 작성하지 말고 기본값을 사용자가 넣은 것 처럼. 기본값을 입력하면 된다는 말이야. 그리고 필수로 집수정이낭 흙막이처럼 필수로 필요한 정보에는 선택안함 선택지 자체가 없어야해.
(10-6 사용자)
기존 프로젝트에서 존재하는 예전 데이터 때문에 검증이 어려운것같은데 쉽게 데이터를 초기화하고 신규로직으로 적용되게 하는 방법은 없어?
B 방향이 맞겠네. 새 프로젝트를 만드는건 문제없어보여. 이때 개별 워크트리들이 검증할수 있는 프로젝트를 각각지정해주는게 좋겠어. 그리고 추가로 엄상준과 박동철 프로젝트도 같이 만들어줘. 같은회사 검증자 들이야. 이 사람들도 본인의 프로젝트로 검증할거야.
이전 프로젝트는 그냥 삭제해
전부 다 손대도 상관없음 프로젝트 전역 초기
@@ -42,6 +42,12 @@ def test_TS_가_쓰는_칸이_파일에_있다() -> None:
def test_B05_높이_칸은_제안_없이_설계자_입력() -> None:
# 독립 기슭막이 높이 = 기본값 없음 · 「설계자 입력」 안내(제안값 단추 걷음 · PLAN 46-7)
assert 'createRevetmentFields(keys, { height: "" })' in FIELDS and '"설계자 입력"' in FIELDS
# 독립 기슭막이 높이 = 기본값 없음 · 빈 칸 그대로(54-43 · 자리표시 글 없음)
assert (
"createRevetmentFields(keys)" in FIELDS
and "placeholder"
not in FIELDS.split("createRevetmentFields(keys: RevetmentKeys)")[1].split(
"createBasinSplitFields"
)[0]
)
assert "기슭막이 높이 없음 — 근입만 그림" in CHROME
@@ -0,0 +1,48 @@
/* 54-43 — 구조물 배치 상자: 빈 칸 = 그냥 빈 칸 · 필수 칸 빈 보기 없음 · 빈 옛 값 = 기본값
* 시작 화면: B05 종단설계(#/b05-profile) 또는 B06 횡단설계(#/b06-section) · 검증 프로젝트(sub5_검증) · 로그인
* 부르는 법: 한 번 — 결과 `sessionStorage.getItem("__5443_out")`
* 목록의 구조물 전부를 차례로 열어 「구조물 배치」 상자 글 · 보기 글 · 자리표시 글을 셈
* 쓰는 것: 없음(열기만 · 저장 없음)
* 기대 수치: words = 0 · requiredBlank = 0 · blankText = 0 · requiredEmpty = 0 · optionalBlank > 0(선택 칸 빈 보기 그대로)
*/
(async () => {
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const txt = (el) => el.innerText.replace(/\s+/g, " ").trim();
const WORDS = /선택 안 함|안 정함|비우면|기본/;
const OPTIONAL = new Set(["soil_add", "chute_add", "chute"]);
const out = { rows: 0, words: [], requiredBlank: [], blankText: [], requiredEmpty: [], optionalBlank: 0, seen: {} };
for (let i = 0; i < 50 && !document.querySelector(".b05-route__irregular-list li"); i++) await w(200);
const rows = [...document.querySelectorAll(".b05-route__irregular-list li")];
for (const row of rows) {
const head = txt(row).slice(0, 24);
row.click();
await w(1200);
const panel = document.querySelector(".b05-structure-section");
if (!panel) continue;
out.rows += 1;
const shown = (el) => !!el.offsetParent;
// 보이는 글 · 고르기 보기 글 · 자리표시 글
const texts = [
txt(panel),
...[...panel.querySelectorAll("select")].filter(shown).flatMap((s) => [...s.options].map((o) => o.text)),
...[...panel.querySelectorAll("input")].filter(shown).map((i) => i.placeholder),
];
for (const t of texts) {
const hit = t.match(WORDS);
if (hit) out.words.push(`${head}: …${t.slice(Math.max(0, hit.index - 12), hit.index + 14)}…`);
}
for (const sel of [...panel.querySelectorAll("select")].filter(shown)) {
const wrap = sel.closest("[data-spec-item]");
const box = sel.closest("[data-spec-container]");
const blank = [...sel.options].find((o) => o.value === "");
const name = wrap?.dataset.specItem ?? sel.closest("[data-option-keys]")?.dataset.optionKeys ?? "?";
out.seen[name] = (out.seen[name] ?? 0) + 1;
if (blank && blank.text !== "" && !wrap?.dataset.specShown) out.blankText.push(`${head}: ${name} 「${blank.text}」`);
if (box && !OPTIONAL.has(box.dataset.specContainer)) {
if (blank && !wrap?.dataset.specShown) out.requiredBlank.push(`${head}: ${name}`);
if (!sel.value && !wrap?.dataset.specShown) out.requiredEmpty.push(`${head}: ${name}`);
} else if (blank) out.optionalBlank += 1;
}
}
sessionStorage.setItem("__5443_out", JSON.stringify(out));
})();