From 8aea7b8cbf77b16b2c1e32e21ba71ca46e95bf35 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:03:59 +0900 Subject: [PATCH] =?UTF-8?q?fix(structure):=20=EB=B9=88=20=C2=B7=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B9=B8?= =?UTF-8?q?=EC=97=90=20=EA=B7=B8=EB=A6=BC=EC=9D=B4=20=EC=93=B0=EB=8A=94=20?= =?UTF-8?q?=EA=B0=92=20=ED=91=9C=EC=8B=9C(54-13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 명세 폼 고르기 칸 — 비었거나 자동 규칙이면 빈 보기 자리에 「값 (기본)」 · 「값 (자동)」 · 고른 값과 구별 · 저장 값 그대로 - 배관: 관경 · 유출 구조 = 정의 기본값 · 유입 구조 · 집수정 모양 · 벽 형태 = 횡단 기하 결과(세션 유입 형식 반영) - 옛 「자동(규칙)」(유입 기슭막이 + 형식 없음) = 「집수정 (자동)」 등 그림 결과 · 유입 구조를 고를 때까지 형식 revet 안 굳음 - 그림 값을 보이던 위 칸 아래를 고치면 그 위 값을 고른 것(집수정 높이 → 집수정) - 세월교 관경 · 유입 · 유출 / BOX 규격 · 유입 · 유출 = 정의 기본값(날개벽 설치 규칙 = 엔진과 같음) - 정의 그룹 꼴 칸 — 빈 칸에 정의 기본값이 있으면 「값 (기본)」 - 시험: ui_checks/54-13_그림값_표시.js Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- A00_Common/design/design_structure_form.ts | 58 +++- .../design/design_structure_pipe_calc.ts | 35 +++ A00_Common/design/design_view_cross.ts | 3 + .../B05_Profile_UI_Drainage_Facility.ts | 95 ++++++- .../B05_Profile_UI_Structures_Fields.ts | 9 +- .../tester/ui_checks/54-13_그림값_표시.js | 255 ++++++++++++++++++ 6 files changed, 441 insertions(+), 14 deletions(-) create mode 100644 resources/tester/ui_checks/54-13_그림값_표시.js diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index 4adf4a6c4..875291406 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -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, + 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(); + 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; }); diff --git a/A00_Common/design/design_structure_pipe_calc.ts b/A00_Common/design/design_structure_pipe_calc.ts index 6e60c27bf..357ab0857 100644 --- a/A00_Common/design/design_structure_pipe_calc.ts +++ b/A00_Common/design/design_structure_pipe_calc.ts @@ -25,6 +25,13 @@ export function setPipeExtraCountSource(source: (section: CrossSection) => numbe extraCountSource = source; } +/** 유입 형식을 대는 곳 — 횡단 보기의 세션 값 우선(`valueFor`) · 없으면 정본 patch(54-13). */ +type InletChoice = NonNullable["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; + extras: Array; +} | 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; diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 065766020..0ce481802 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -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) => { diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 35ee3c8ea..15d55e4c7 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -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; 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 = {}; + 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 | undefined; + const drawnNow = (): ReturnType => { + 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])); diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index 3ea90331b..8f446c04f 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -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([ diff --git a/resources/tester/ui_checks/54-13_그림값_표시.js b/resources/tester/ui_checks/54-13_그림값_표시.js new file mode 100644 index 000000000..07f6a2918 --- /dev/null +++ b/resources/tester/ui_checks/54-13_그림값_표시.js @@ -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(); +})();