Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1

This commit is contained in:
2026-10-06 12:05:58 +09:00
6 changed files with 441 additions and 14 deletions
+49 -9
View File
@@ -59,6 +59,14 @@ export interface SpecFormHooks {
* [추가] · [빼기] 없음 · 줄면 끝에서부터 가림(값은 남음 · 54-10) · null = 모름(저장된 것 그대로) ·
* undefined = 손 [추가]. */
count?: (c: SpecContainer) => number | null | undefined;
/** 그림이 쓰는 값(54-13) — 고르기 칸이 비었거나 자동 규칙일 때 그 값 · 꼬리(「기본」 · 「자동」) ·
* null = 없음. 보여 주기만(값 · 저장은 사용자가 고를 때만). `values` = 보이는 값(위 칸 반영). */
shown?: (
c: SpecContainer,
item: SpecItem,
values: Record<string, string>,
index: number,
) => { value: string; tag: string } | null;
}
export interface SpecForm {
@@ -105,18 +113,27 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
return slot;
};
const control = (c: SpecContainer, item: SpecItem, values: Instance): HTMLElement => {
const control = (
c: SpecContainer,
item: SpecItem,
values: Instance,
tag?: string,
): HTMLElement => {
if (item.kind === "select") {
const select = document.createElement("select");
const choices = choicesOf(item, values);
const now = values[item.id] ?? "";
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;
select.replaceChildren(
new Option("— 안 정함 —", ""),
new Option(blank, ""),
...choices.map((ch) => new Option(choiceLabel(c, item, ch, names), ch.value)),
);
const now = values[item.id] ?? "";
// 선택지 밖 옛 값은 그 값만 남겨 보임(지운 값을 몰래 바꾸지 않음)
if (now && !choices.some((ch) => ch.value === now)) select.add(new Option(now, now));
select.value = now;
if (!tag && now && !drawn) select.add(new Option(now, now));
select.value = tag ? "" : now;
return select;
}
if (item.kind === "text") {
@@ -139,7 +156,19 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
return stepper(input, item.step ?? 0.1);
};
const onEdit = (c: SpecContainer, item: SpecItem, values: Instance, raw: string): void => {
const onEdit = (
c: SpecContainer,
item: SpecItem,
values: Instance,
raw: string,
view: Instance,
): void => {
// 그림 값을 보이던 위 칸 아래를 고침 = 그 위 값을 고른 것 — 다른 갈래 값은 비움(54-13)
const parent = item.by?.item;
if (parent && view[parent] !== values[parent]) {
values[parent] = view[parent];
for (const child of c.items) if (child.by?.item === parent) values[child.id] = "";
}
values[item.id] = raw;
if (item.split) {
const order = [item.split, ...(recent.get(values) ?? []).filter((r) => r !== item.split)];
@@ -170,15 +199,26 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
const title = containerTitle(c, names, groupNames[c.link]);
const box = group(c.required ? title : `${title} ${index + 1}`);
box.root.dataset.specContainer = c.id;
// 보이는 값 = 고른 값 + 그림이 쓰는 값(54-13) · 위 칸이 다른 갈래를 보이면 아래 칸은 빈 채로
const view: Instance = { ...values };
const tags = new Map<string, string>();
for (const item of c.items) {
if (item.by && view[item.by.item] !== values[item.by.item]) view[item.id] = "";
const drawn = item.kind === "select" ? hooks.shown?.(c, item, view, index) : null;
if (!drawn) continue;
view[item.id] = drawn.value;
tags.set(item.id, drawn.tag);
}
const fields = c.items
.filter((item) => item.kind === "auto" || !item.by || branchOf(item, values))
.filter((item) => item.kind === "auto" || !item.by || branchOf(item, view))
.map((item) => {
const input = control(c, item, values);
const input = control(c, item, view, tags.get(item.id));
const wrap = labeled(itemLabel(c, item, names), input);
wrap.dataset.specItem = `${c.id}.${item.id}`;
if (tags.has(item.id)) wrap.dataset.specShown = tags.get(item.id);
const target = input.querySelector?.("input, select") ?? input;
target.addEventListener("change", () =>
onEdit(c, item, values, (target as HTMLInputElement | HTMLSelectElement).value),
onEdit(c, item, values, (target as HTMLInputElement | HTMLSelectElement).value, view),
);
return wrap;
});
@@ -25,6 +25,13 @@ export function setPipeExtraCountSource(source: (section: CrossSection) => numbe
extraCountSource = source;
}
/** 유입 형식을 대는 곳 — 횡단 보기의 세션 값 우선(`valueFor`) · 없으면 정본 patch(54-13). */
type InletChoice = NonNullable<CrossSection["design"]>["inlet_structure"];
let inletSource: ((section: CrossSection) => InletChoice) | null = null;
export function setPipeInletSource(source: (section: CrossSection) => InletChoice): void {
inletSource = source;
}
/** 그 관의 주인 측점(관 자리에서 가장 가까운 측점) — 횡단 자료가 없으면 null. */
function ownerSection(
chainageM: number | null,
@@ -64,6 +71,34 @@ export function pipeExtraCount(chainageM: number | null): number | null {
return computeStoredLayouts(probe, sections)?.culvert?.extraWalls.length ?? outlet;
}
/** 그림이 쓰는 유입 · 유출 값(54-13) — 집수정 모양(없으면 null = 흙막이 벽) · 벽 형태(유입 · 유출 ·
* 흙막이 n). 폼 빈 · 자동 칸에 보여 주기만. 횡단 자료가 없으면 null(모름). */
export function pipeDrawn(chainageM: number | null): {
basin: string | null;
forms: Record<string, string | null>;
extras: Array<string | null>;
} | null {
const found = ownerSection(chainageM);
const design = found?.owner.design;
if (!found || !design) return null;
const { owner, sections } = found;
const outlet = extraCountSource?.(owner) ?? design.extra_wall_counts?.outlet ?? 0;
const counts = { basin: 0, ...design.extra_wall_counts, outlet };
const inlet = inletSource?.(owner) ?? design.inlet_structure;
const probe = {
...owner,
design: { ...design, extra_wall_counts: counts, inlet_structure: inlet },
};
const layout = computeStoredLayouts(probe, sections)?.culvert;
if (!layout) return null;
const formOf = (role: string) => layout.walls.find((w) => w.role === role)?.form ?? null;
return {
basin: layout.basin?.shape ?? null,
forms: { inlet: formOf("inlet"), outlet: formOf("outlet") },
extras: layout.extraWalls.map((w) => w.form),
};
}
/** 흙막이 높이 빈 칸 안내 — 관경 기준 최소 높이(엔진이 빈 칸을 이 값으로 그림). */
export function guardHeightHint(diameterMm: string): string {
const d = Number(diameterMm || "1000") / 1000;
+3
View File
@@ -76,6 +76,7 @@ import type { DesignPageHost } from "./design_page";
import {
pipeExtraCount,
setPipeExtraCountSource,
setPipeInletSource,
setPipeSectionsSource,
} from "./design_structure_pipe_calc";
import {
@@ -296,6 +297,8 @@ export async function mountCrossView(
});
// 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선
setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet"));
// 폼이 보이는 유입 그림 값(54-13) — 세션 형식 우선
setPipeInletSource((section) => stationControls.inletStructure.valueFor(section));
const sectionView = createSectionView(
(chainageM, change) => {
@@ -46,6 +46,7 @@ import {
WING_OUT_KEYS,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form";
import { branchOf } from "../A00_Common/design/design_structure_spec";
import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe";
import {
BOX_SPEC,
@@ -57,6 +58,7 @@ import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structu
import { fordHeightHint, fordHeightM } from "../A00_Common/design/design_structure_ford_calc";
import {
guardHeightHint,
pipeDrawn,
pipeExtraCount,
pipeLengthM,
} from "../A00_Common/design/design_structure_pipe_calc";
@@ -212,6 +214,8 @@ export interface FacilityOptionsForm {
definition?: {
spec_names?: Record<string, string>;
groups?: Array<{ id: string; name: string }>;
/** 옵션 정의 기본값 — 빈 고르기 칸이 그림 값을 보임(54-13). */
options?: Array<{ key: string; default: unknown }>;
} | null,
) => void;
/** 배관 명세 컨테이너 「구조물 조합」 잇기 자리 — 폼이 패널 그룹 값에 이음(PLAN 52-1). */
@@ -267,7 +271,52 @@ export function createFacilityOptionsForm(
clear: (key) => linkSource?.clear(key),
set: (key, item) => linkSource?.set(key, item),
};
// 그림이 쓰는 값(54-13) — 빈 고르기 칸 = 정의 기본값(엔진 · 횡단도가 그 값으로 그림) · 보여 주기만
let defaults: Record<string, unknown> = {};
const byDefault = (key: string | undefined): { value: string; tag: string } | null => {
const value = key ? defaults[key] : null;
return value === null || value === undefined || value === ""
? null
: { value: String(value), tag: "기본" };
};
/** 빈 날개벽 설치 = 정의 기본 설치(옛 「있음」 · 빈 값 = 날개벽 · 엔진 `_wing_spec`). */
const wingDefault = (key: string): { value: string; tag: string } => {
const value = String(defaults[key] ?? "");
return { value: ["없음", "흙막이"].includes(value) ? value : "날개벽", tag: "기본" };
};
// 유입 「자동(규칙)」 = 기슭막이 + 유입 형식 auto(옛 값 · 정의 기본) — 그림은 지형 규칙이 집수정 · 흙막이를
// 정함. 사용자가 유입 구조를 고를 때까지 저장도 그대로(흙막이 고름의 형식 revet 을 안 적음)
let inletAuto: { structure?: string | number } | null = null;
const inletIsAuto = (): boolean =>
!!inletAuto && pipeForm.state().inlet?.[0]?.structure === "기슭막이";
// 그림 계산은 한 번 그릴 동안 한 번만(칸마다 부름)
let drawnMemo: ReturnType<typeof pipeDrawn> | undefined;
const drawnNow = (): ReturnType<typeof pipeDrawn> => {
if (drawnMemo === undefined) {
drawnMemo = pipeDrawn(chainage);
queueMicrotask(() => (drawnMemo = undefined));
}
return drawnMemo;
};
const pipeForm = createSpecForm(specLinks, {
shown: (c, item, values, index) => {
if (c.id === "inlet" && item.id === "structure" && (!values.structure || inletIsAuto())) {
const drawn = drawnNow();
return drawn
? { value: drawn.basin ? "집수정" : "기슭막이", tag: "자동" }
: { value: "", tag: "자동(지형 규칙)" };
}
if (values[item.id]) return null;
if (item.id !== "form") return byDefault(branchOf(item, values)?.key);
const basin = c.id === "inlet" && values.structure === "집수정";
const drawn = drawnNow();
const form = basin
? drawn?.basin
: c.id === "soil_add"
? drawn?.extras[index]
: drawn?.forms[c.id];
return form ? { value: form, tag: basin ? "자동" : "기본" } : null;
},
auto: (_c, item) => {
if (item.id !== "length") return "";
const length = pipeLengthM(chainage);
@@ -276,13 +325,21 @@ export function createFacilityOptionsForm(
// 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1)
count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined),
hint: (c, item, state) => {
const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이";
const soil =
c.id !== "inlet" ||
(state.inlet?.[0]?.structure === "기슭막이" && !(inletIsAuto() && drawnNow()?.basin));
return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : "";
},
onChange: (c, item) => {
if (c.id === "inlet" && item.id === "structure") inletAuto = null;
if (c.id === "inlet" && (item.id === "structure" || item.id === "form"))
inletStructureHandler?.(inletStructureOf());
emit();
// 고른 뒤 그림이 바뀜 — 자동 표시를 새 그림으로 다시 그림(54-13)
if (c.id === "inlet") {
drawnMemo = undefined;
pipeForm.refresh();
}
},
});
// 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — 유입 · 유출 컨테이너 끝(2026-08-29 지시 4)
@@ -303,6 +360,11 @@ export function createFacilityOptionsForm(
const fordNeed = (): number | null =>
fordHeightM(fordFlow, Number(fordForm.state().ford?.[0]?.width ?? ""));
const fordForm = createSpecForm(specLinks, {
shown: (c, item, values) => {
if (values[item.id]) return null;
if (c.id !== "ford") return wingDefault(c.id === "inlet" ? "wing_in" : "wing_out");
return item.id === "diameter" ? byDefault("pipe_diameter_mm") : null;
},
hint: (c, item, state) =>
c.id === "ford" && item.id === "height"
? fordHeightHint(fordFlow, Number(state.ford?.[0]?.width ?? ""))
@@ -368,7 +430,18 @@ export function createFacilityOptionsForm(
};
// ── BOX암거 = 사용자 구조물 표 02 명세(PLAN 52-2) — 본체 · 유입 구조 · 유출 구조 ──
const boxForm = createSpecForm(specLinks, { onChange: () => emit() });
const boxForm = createSpecForm(specLinks, {
onChange: () => emit(),
shown: (c, item, values) => {
if (values[item.id]) return null;
if (c.id !== "body") return wingDefault(c.id === "inlet" ? "wing_in" : "wing_out");
const size = bodyOf({
body_width_m: Number(defaults.body_width_m ?? 2),
body_height_m: Number(defaults.body_height_m ?? 2),
}).size;
return item.id === "size" && size ? { value: size, tag: "기본" } : null;
},
});
// 날개벽 칸 — 세월교 · BOX 가 같은 조각을 쓰던 자리 · 조정 행은 명세 유입 · 유출 칸으로 옮겨 붙음
const wingInFields = createWingFields("날개벽(유입)", WING_IN_KEYS);
const wingOutFields = createWingFields("날개벽(유출)", WING_OUT_KEYS);
@@ -460,8 +533,10 @@ export function createFacilityOptionsForm(
inletStructure: inletStructureOf,
setInletStructure(value) {
// 조정창 형식 → 유입 보호공 구조 · 형태(자동 = 고른 구조는 지키고 형태만 비움)
if (value === "revet") pipeForm.setValue("inlet", "structure", "기슭막이");
else if (value !== "auto") pipeForm.setValue("inlet", "structure", "집수정");
if (value === "revet") {
inletAuto = null;
pipeForm.setValue("inlet", "structure", "기슭막이");
} else if (value !== "auto") pipeForm.setValue("inlet", "structure", "집수정");
if (value !== "revet" && pipeForm.state().inlet?.[0]?.structure === "집수정")
pipeForm.setValue("inlet", "form", value === "auto" ? "" : value);
syncVisibility();
@@ -505,9 +580,16 @@ export function createFacilityOptionsForm(
// 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7).
const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name]));
const names = definition?.spec_names ?? {};
defaults = Object.fromEntries((definition?.options ?? []).map((o) => [o.key, o.default]));
if (facility === "pipe") {
// 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1)
const { soil_add: _old, ...pipeNames } = names;
const kind = options.inlet_structure;
inletAuto =
options.inlet_type === "기슭막이" &&
!["revet", "I", "L", "U"].includes(String(kind ?? ""))
? { structure: kind }
: null;
pipeForm.write(PIPE_SPEC, options, pipeNames, groups);
}
if (facility === "box_culvert") {
@@ -537,6 +619,11 @@ export function createFacilityOptionsForm(
// 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈).
if (current === "pipe") {
Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1)
// 자동(규칙)은 고를 때까지 그대로 — 보여 주기만으로 파일이 안 바뀜(54-13)
if (inletIsAuto()) {
if (inletAuto!.structure === undefined) delete options.inlet_structure;
else options.inlet_structure = inletAuto!.structure;
}
} else if (current === "box_culvert") {
// 정본은 폭 · 높이 두 칸 — 규격이면 규격 수 · 사용자 입력이면 명세 숫자 칸(PLAN 52-2)
Object.assign(options, boxForm.read(), bodyOptions(boxForm.state().body?.[0]));
@@ -65,7 +65,14 @@ export function optionControl(
// 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻.
const tip = option.not_in_table ?? option.empty_means ?? "";
if (option.input === "select") {
const blank = option.required ? "— 선택 —" : "— 안 정함 —";
// 빈 칸이 쓰는 정의 기본값은 빈 보기에 꼬리를 달아 보임 — 값은 안 고름(54-13)
const fallback = option.default === null || option.default === "" ? null : option.default;
const blank =
fallback !== null && fallback !== undefined
? `${label(String(fallback))} (기본)`
: option.required
? "— 선택 —"
: "— 안 정함 —";
// 정의에서 뺀 선택지(48)를 고른 옛 값 — 그 값만 남겨 보임(값을 잃지 않음)
const kept = value && !option.choices.includes(value) ? [value] : [];
const element = select([
@@ -0,0 +1,255 @@
/* 54-13 — 좌측 폼 고르기 칸이 그림이 쓰는 값(기본값 · 자동 규칙 결과)을 보이는가 · 보여 주기만으로 값이 안 바뀌는가
* 시작 화면: B06 횡단설계(#/b06-section) 또는 B05 종단설계(#/b05-profile) · 검증 프로젝트(sub6_검증) · 로그인
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5413 = "단계";` · 결과 `sessionStorage.getItem("__5413_out_{단계}")`
* check → 목록의 배관 · 세월교 · BOX 전부를 열어 폼 글 ↔ 그림(B06 카드 SVG 글 · 벽 툴팁 · 상세 세트) 대조 ·
* B05 에서 부르면 B06 에서 읽은 폼 글과 같은지 · 초안(관 지점 세션) 안 생김
* old → 4+5 배관을 옛 「자동(규칙)」(유입 기슭막이 · 형식 없음)으로 초안에 넣고 새로 고침
* oldpick→ (새로 고친 뒤) 폼 = 그림 · 관경만 고르면 유입 형식 안 굳음 · 흙막이 고르면 revet · 그림 벽 ·
* 12+18 배관 집수정 높이 고침 = 집수정을 고른 것
* box → 17+12 세월교(빈 칸) 대조 · 세션에서만 BOX 로 바꾸고 칸을 비워 규격 · 유입 · 유출 = 기본 · 저장 칸 없음
* restore→ 초안 지우고 새로 고침 · 서버 관 지점 = 처음 사진
* 쓰는 것: sessionStorage 만(서버 저장 없음)
* 기대 수치: 모든 단계 bad = []
*/
(async () => {
const MODE = window.__m5413 || "check";
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 15000) => {
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 DRAFT = `aislo:draft:pipes:${pid}`;
const B06 = location.hash.includes("b06");
const txt = (el) => el.innerText.replace(/\s+/g, " ").trim();
const flat = (s) => String(s ?? "").replace(/\s+/g, "");
const out = { mode: MODE, page: B06 ? "B06" : "B05", bad: [] };
const bad = (what) => out.bad.push(what);
const done = () => sessionStorage.setItem(`__5413_out_${MODE}`, JSON.stringify(out));
const api = async () =>
(await (await fetch(`/api/projects/${pid}/drainage/pipe-points`)).json()).pipe_points;
const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")];
const rowOf = (head) => rows().find((li) => txt(li).startsWith(head));
const draft = (at) => {
const raw = sessionStorage.getItem(DRAFT);
const list = raw ? (JSON.parse(raw).value ?? JSON.parse(raw)) : null;
return list?.find((p) => Math.abs(p.chainage_m - at) < 0.51) ?? null;
};
const spec = () =>
[...document.querySelectorAll(".b05-structure__spec")].find((s) => s.offsetParent);
/** 폼 고르기 칸 `{항목: 보이는 글}` · 꼬리 표시(data-spec-shown) 칸 목록. */
const formOf = () => {
const fields = {};
const tagged = [];
for (const wrap of spec()?.querySelectorAll("[data-spec-item]") ?? []) {
const sel = wrap.querySelector("select");
if (!sel) continue;
fields[wrap.dataset.specItem] = sel.options[sel.selectedIndex]?.text ?? "";
if (wrap.dataset.specShown) tagged.push(wrap.dataset.specItem);
}
return { fields, tagged };
};
const detailAt = (at) => {
const key = Object.keys(sessionStorage).find((k) => k.includes("section-detail"));
const raw = key ? JSON.parse(sessionStorage.getItem(key)) : null;
const list = (raw?.value ?? raw)?.cross_sections ?? [];
return list.reduce(
(a, b) => (Math.abs(b.chainage_m - at) < Math.abs((a?.chainage_m ?? 1e9) - at) ? b : a),
null,
);
};
/** 그림 — 고른 카드 SVG 글 · 벽 툴팁(역할 + 형태). */
const drawn = () => {
const svg = document.querySelector(".b06-cross-card--selected svg");
if (!svg) return null;
return {
labels: [...svg.querySelectorAll(".b06-chart__culvert-label")].map((t) => t.textContent),
walls: [...svg.querySelectorAll(".b06-chart__culvert-revet title")].map(
(t) => t.textContent.split(" H=")[0],
),
};
};
const open = async (head) => {
const row = await until(() => rowOf(head)).then(() => rowOf(head));
if (!row) return (bad(`${head}: 목록에 없음`), false);
row.click();
await until(() => spec() && (!B06 || document.querySelector(".b06-cross-card--selected svg")));
await w(1200);
return true;
};
const fire = (sel, value) => {
sel.value = value;
sel.dispatchEvent(new Event("change", { bubbles: true }));
};
const selectOf = (item) => spec()?.querySelector(`[data-spec-item="${item}"] select`);
const SHAPE = { I형: "I형", L형: "ㄴ형", ㄷ형: "ㄷ형" };
/** 폼 글 ↔ 그림 — 종류마다 그림이 쓰는 값을 따로 읽어 대조. */
const compare = (head, at, kind) => {
const { fields, tagged } = formOf();
const pic = B06 ? drawn() : null;
const set = detailAt(at);
const got = { head, fields, tagged, pic };
const blank = Object.entries(fields).filter(
([k, v]) => v === "— 안 정함 —" && !/^(soil_add|chute_add)/.test(k),
);
if (blank.length) bad(`${head}: 빈 칸 글 ${blank.map(([k]) => k)}`);
if (!pic) return got; // B05 — 글 대조는 B06 사진과
const head0 = (v) => flat(v).replace(/\((기본|자동)\)$/, "");
if (kind === "pipe") {
const basin = pic.labels.find((l) => l.startsWith("집수정"));
const inlet = head0(fields["inlet.structure"]);
if ((inlet === "집수정") !== !!basin) bad(`${head}: 유입 ${inlet} ↔ 그림 ${basin ?? "벽"}`);
const form = head0(fields["inlet.form"]);
if (basin && !basin.includes(SHAPE[form] ?? "?")) bad(`${head}: 모양 ${form} ↔ ${basin}`);
for (const [side, role] of [
["inlet", "유입"],
["outlet", "유출"],
]) {
const wall = pic.walls.find((t) => t.startsWith(`${role} 기슭막이`));
if (side === "inlet" && basin) continue;
const f = head0(fields[`${side}.form`]);
if (!wall || flat(wall) !== flat(`${role} 기슭막이 ${f}`))
bad(`${head}: ${side} 형태 ${f} ↔ ${wall}`);
}
const d = Math.round((set?.culvert?.diameter_m ?? 0) * 1000);
if (tagged.includes("pipe.diameter") && head0(fields["pipe.diameter"]) !== String(d))
bad(`${head}: 관경 ${fields["pipe.diameter"]} ↔ ${d}`);
}
if (kind === "ford") {
const label = pic.labels.find((l) => l.includes("Ø")) ?? "";
if (!label.includes(`Ø${head0(fields["ford.diameter"])}`))
bad(`${head}: 관경 ${fields["ford.diameter"]} ↔ ${label}`);
for (const side of ["in", "out"]) {
const wing = set?.ford?.[`wing_${side}`]?.installed;
const text = head0(fields[`${side === "in" ? "inlet" : "outlet"}.structure`]);
if (wing !== undefined && (text === "날개벽") !== wing)
bad(`${head}: ${side} ${text} ↔ 설치 ${wing}`);
}
}
if (kind === "box") {
const label = pic.labels.find((l) => l.startsWith("BOX")) ?? "";
if (flat(label) !== `BOX${head0(fields["body.size"])}`)
bad(`${head}: 규격 ${fields["body.size"]} ↔ ${label}`);
}
return got;
};
const KIND = { 배관: "pipe", 세월교: "ford", BOX암거: "box" };
try {
if (MODE === "check") {
if (!sessionStorage.getItem("__5413_orig"))
sessionStorage.setItem("__5413_orig", JSON.stringify(await api()));
const before = sessionStorage.getItem(DRAFT);
const heads = rows()
.map(txt)
.filter((t) => /^\d+\+\d+ (배관|세월교|BOX암거)/.test(t));
const b06 = JSON.parse(sessionStorage.getItem("__5413_b06") ?? "{}");
out.rows = [];
for (const t of heads) {
const head = t.split(" ").slice(0, 2).join(" ");
if (!(await open(head))) continue;
const station = head.split(" ")[0].split("+").map(Number);
const got = compare(head, station[0] * 20 + station[1], KIND[t.split(" ")[1]]);
out.rows.push(`${head} ${JSON.stringify(got.fields)} | ${JSON.stringify(got.pic)}`);
if (B06) b06[t] = got.fields;
else if (b06[t] && JSON.stringify(b06[t]) !== JSON.stringify(got.fields))
bad(`${head}: B05 ${JSON.stringify(got.fields)} ≠ B06 ${JSON.stringify(b06[t])}`);
else if (!b06[t]) bad(`${head}: B06 사진 없음 — B06 에서 check 먼저`);
}
if (B06) sessionStorage.setItem("__5413_b06", JSON.stringify(b06));
if (sessionStorage.getItem(DRAFT) !== before) bad("열기만 했는데 관 초안이 바뀜");
out.count = heads.length;
} else if (MODE === "old") {
const list = (await api()).map(({ lonlat, ...rest }) => rest);
const pipe = list.find((p) => Math.abs(p.chainage_m - 85.05) < 0.51);
pipe.options = { ...pipe.options, inlet_type: "기슭막이" };
delete pipe.options.inlet_structure;
sessionStorage.setItem(DRAFT, JSON.stringify(list));
out.note = "초안 넣음 — 새로 고친 뒤 oldpick";
done();
location.reload();
return;
} else if (MODE === "oldpick") {
await open("4+5 배관");
out.start = compare("4+5 옛 자동", 85.05, "pipe");
if (!flat(out.start.fields["inlet.structure"]).endsWith("(자동)"))
bad(`옛 자동 유입 글 ${out.start.fields["inlet.structure"]}`);
// 관경만 고름 — 유입 형식은 그대로(자동 유지)
fire(selectOf("pipe.diameter"), "1200");
await w(2500);
out.afterDiameter = draft(85.05)?.options;
if (out.afterDiameter?.inlet_structure !== undefined)
bad(`관경만 골랐는데 형식 ${out.afterDiameter.inlet_structure}`);
if (out.afterDiameter?.inlet_type !== "기슭막이")
bad(`유입 저장 값 바뀜 ${out.afterDiameter?.inlet_type}`);
out.stillAuto = formOf().fields["inlet.structure"];
// 흙막이를 고름 = 형식 revet · 그림 벽
fire(selectOf("inlet.structure"), "기슭막이");
await w(3500);
out.afterPick = draft(85.05)?.options;
if (out.afterPick?.inlet_structure !== "revet")
bad(`흙막이 골랐는데 형식 ${out.afterPick?.inlet_structure}`);
out.picked = compare("4+5 흙막이 고름", 85.05, "pipe");
if (out.picked.fields["inlet.structure"] !== "흙막이")
bad(`고른 글 ${out.picked.fields["inlet.structure"]}`);
// 다른 관(자동 집수정) — 집수정 높이 고침 = 집수정을 고른 것
await open("12+18 배관");
const shown = formOf().fields["inlet.structure"];
out.other = shown;
if (flat(shown) === "집수정(자동)") {
const h = spec().querySelector('[data-spec-item="inlet.height"] input');
h.value = "1.5";
h.dispatchEvent(new Event("change", { bubbles: true }));
await w(3000);
const o = draft(258.12)?.options;
out.basinEdit = o;
if (o?.inlet_type !== "집수정" || o?.inlet_basin_height_m !== 1.5)
bad(`집수정 높이 고침 저장 ${JSON.stringify(o)}`);
if (formOf().fields["inlet.structure"] !== "집수정")
bad(`높이 고친 뒤 글 ${formOf().fields["inlet.structure"]}`);
}
} else if (MODE === "box") {
await open("17+12 세월교");
out.ford = compare("17+12 세월교", 352.14, "ford");
const type = document.querySelectorAll(
".b05-structure-section .b05-structure__grid select",
)[1];
fire(type, "box_culvert");
await until(() => rowOf("17+12 BOX암거"));
await w(3000);
// 새 종류는 정의 기본값이 값으로 실림(46-7) — 칸을 비워 옛 빈 저장과 같게
for (const k of ["body.size", "inlet.structure", "outlet.structure"]) {
fire(selectOf(k), "");
await w(1500);
}
await w(2000);
out.box = compare("17+12 BOX", 352.14, "box");
const f = out.box.fields;
if (flat(f["body.size"]) !== "2.0×2.0(기본)") bad(`BOX 규격 ${f["body.size"]}`);
for (const k of ["inlet.structure", "outlet.structure"])
if (flat(f[k]) !== "날개벽(기본)") bad(`BOX ${k} ${f[k]}`);
const o = draft(352.14)?.options ?? {};
if (["body_width_m", "wing_in", "wing_out"].some((k) => k in o))
bad(`보여 주기만 했는데 BOX 저장 칸 ${JSON.stringify(o)}`);
} else if (MODE === "restore") {
sessionStorage.removeItem(DRAFT);
// 고르기 단계가 남긴 B06 유입 형식 세션 · 그걸 얹은 상세 캐시(다시 받음)
for (const key of Object.keys(sessionStorage))
if (key.includes(`inletstruct:${pid}`) || key.includes(`section-detail:${pid}`))
sessionStorage.removeItem(key);
const orig = JSON.parse(sessionStorage.getItem("__5413_orig") ?? "null");
const now = await api();
out.same = JSON.stringify(now) === JSON.stringify(orig);
if (!out.same) bad("서버 관 지점이 처음 사진과 다름");
done();
location.reload();
return;
}
} catch (error) {
bad(String(error?.stack ?? error));
}
done();
})();