fix(structure): 빈 · 자동 고르기 칸에 그림이 쓰는 값 표시(54-13)

- 명세 폼 고르기 칸 — 비었거나 자동 규칙이면 빈 보기 자리에 「값 (기본)」 · 「값 (자동)」 · 고른 값과 구별 · 저장 값 그대로
- 배관: 관경 · 유출 구조 = 정의 기본값 · 유입 구조 · 집수정 모양 · 벽 형태 = 횡단 기하 결과(세션 유입 형식 반영)
- 옛 「자동(규칙)」(유입 기슭막이 + 형식 없음) = 「집수정 (자동)」 등 그림 결과 · 유입 구조를 고를 때까지 형식 revet 안 굳음
- 그림 값을 보이던 위 칸 아래를 고치면 그 위 값을 고른 것(집수정 높이 → 집수정)
- 세월교 관경 · 유입 · 유출 / BOX 규격 · 유입 · 유출 = 정의 기본값(날개벽 설치 규칙 = 엔진과 같음)
- 정의 그룹 꼴 칸 — 빈 칸에 정의 기본값이 있으면 「값 (기본)」
- 시험: ui_checks/54-13_그림값_표시.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
This commit is contained in:
2026-10-06 12:03:59 +09:00
co-authored by Claude Opus 5.5
parent 8759fbf0ee
commit 8aea7b8cbf
6 changed files with 441 additions and 14 deletions
@@ -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();
})();