diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 37d5c757f..24958ad89 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -118,6 +118,8 @@ export const STATE_REGISTRY = { }, /** 설계 가게(`design/design_store`)의 고른 측점 · 구조물 — `{chainageM, structureId}`. */ selection: { bucket: "draft", scope: "project" }, + /** 구조물 패널 종류별 캐시 — 고른 구조물 하나 × 종류마다 옛 값(54-32). [저장]·[확정] 뒤 비움. */ + "structure-type-cache": { bucket: "draft", scope: "project" }, /** 배수관·암거 조정창에서 예약한 옵션 값. */ culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` }, /** 배수관 이동(측점 옮김) 예약. */ diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index 0dbbaa722..8c8bad1fa 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -25,7 +25,7 @@ import { pendingAlignment, } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; import { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options"; -import { stateKey } from "../b_page_state"; +import { clearState, stateKey } from "../b_page_state"; /** 저장 차례 — 이 순서 그대로 돈다. */ export const DESIGN_SAVE_ORDER = [ @@ -134,6 +134,7 @@ export async function saveDesign( const label = TOLERANT[step]; await (label ? run().catch(reportOnly(label)) : run()); } + clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32) await ctx.done?.(confirm); return true; } catch (error) { diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 2e2f95114..9fe5b52d9 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -474,6 +474,7 @@ export function createStructuresSection( endFields, facilityOptions, callbacks, + projectId: sectionOptions.projectId ?? null, structures, types: () => types, typeMap, diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts index 140499f9b..8f3e49e41 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts @@ -10,10 +10,16 @@ import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; import type { StructuresCommitContext } from "./B05_Profile_UI_Structures_Panel_Commit"; +import { asRoad } from "./B05_Profile_UI_Structures_Define"; +import { readState, writeState } from "../A00_Common/b_page_state"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { readGroupItems, readSummaryItem } from "../common_util/common_util_summary_item"; /** 배선에 필요한 패널 내부 값 — 이름은 분리 전 지역변수와 같다. */ /** 배선에 필요한 값 — 저장 쪽(`StructuresCommitContext`)과 겹치는 것은 그대로 물려받는다. */ export interface StructuresEventContext extends StructuresCommitContext { + /** 종류별 캐시(54-32) 세션 키의 프로젝트. */ + projectId: string | null; typeSelect: HTMLSelectElement; primary: HTMLButtonElement; removeButton: HTMLButtonElement; @@ -31,34 +37,103 @@ export interface StructuresEventContext extends StructuresCommitContext { } export function bindStructuresEvents(ctx: StructuresEventContext): void { + /** 새로 놓기(54-32) — 고름 · 측점 · 강조를 풀고 군(· 종류)만 남긴 빈 기본값 폼. */ + function startNew(group: string, typeId?: string): void { + ctx.resetForm(); + ctx.groupSelect.value = group; + ctx.syncTypeOptions(typeId); + } ctx.groupSelect.addEventListener("change", () => { ctx.cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18). - ctx.syncTypeOptions(); - // 종류가 바뀌면 기존 항목 수정이 아니라 새 항목 추가로 넘어간다(옵션 스키마가 달라진다). - if (ctx.editingId()) { - ctx.setEditingId(null); - ctx.syncButtons(); - ctx.renderList(); - ctx.callbacks.onSelect(null); - } + // 고른 항목에서 군을 바꿈 = 새 항목 추가(54-32) — 고른 항목 값은 그대로 + if (ctx.editingId() || ctx.selectedPipeChainage() !== null) startNew(ctx.groupSelect.value); + else ctx.syncTypeOptions(); }); ctx.typeSelect.addEventListener("change", () => { ctx.cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18). + if (ctx.editingId() || ctx.selectedPipeChainage() !== null) return switchSelectedType(); ctx.syncPlacementFields(); ctx.renderOptionFields(); // 배수관·BOX암거·세월교·독립 기슭막이는 부속 옵션 서브폼이 따라 열려야 한다 — // 수동 추가든 자동 배치 지점 편집이든 같은 옵션을 받는다(2026-08-17 사용자 지시: // 자동은 통수단면으로 자리를 잡아 준 것일 뿐 같은 구조물이다). ctx.syncFacilityForm(); - // 고른 관 지점의 시설 종류를 바꿈(54-4) — 실시간 반영으로 목록 · 알약 · 횡단도가 바로 따라옴 - if (ctx.selectedPipeChainage() !== null && ctx.currentType()?.managed_by) ctx.liveCommit(); - if (ctx.editingId()) { - ctx.setEditingId(null); - ctx.syncButtons(); - ctx.renderList(); - ctx.callbacks.onSelect(null); - } }); + + /** 고른 항목의 종류를 같은 군 안에서 바꿈(54-32) — 그 항목이 새 종류가 됨. 옛 종류 값은 + * 종류별 캐시에 담고, 담아 둔 종류로 돌아오면 그 값으로 · 처음이면 새 종류 기본값으로. + * 관 지점 시설(배관 ↔ 박스 ↔ 세월교 ↔ 물넘이)도 같은 규칙 · 실시간 반영은 54-4 길 그대로. */ + function switchSelectedType(): void { + const next = ctx.currentType(); + const id = ctx.editingId(); + const pipe = ctx.selectedPipeChainage(); + const index = id ? ctx.structures.findIndex((entry) => entry.structure_id === id) : -1; + const record = index >= 0 ? ctx.structures[index] : null; + const pipeItem = + pipe === null + ? null + : ctx.pipeFacilities().find((entry) => Math.abs(entry.chainage_m - pipe) < PIPE_MATCH_M); + const prev = ctx.typeMap().get(record ? asRoad(record).type_id : (pipeItem?.facility ?? "")); + // 정본이 다른 종류(관 지점 ↔ 구조물)로 · 빈칸으로 = 새로 놓기 + if (!next || !prev || !!next.managed_by !== !!prev.managed_by) + return startNew(ctx.groupSelect.value, next?.type_id); + const key = record ? `s:${id}` : `p:${pipe}`; + // 구조물은 정본 기록(안 보이는 상세 칸까지) · 관은 아직 옛 종류로 그려진 폼에서 읽음 + stashTypeValues( + key, + prev.type_id, + record + ? { + options: record.options, + summary_item: record.summary_item, + group_items: record.group_items, + } + : { + options: { ...ctx.facilityOptions.readOptions(), ...ctx.groupBoxes.shapes() }, + summary_item: ctx.summary(), + group_items: ctx.groupBoxes.get(), + }, + ); + const back = recallTypeValues(key, next.type_id); + // 기록을 새 종류로 갈아 둠 — 되살린 값의 안 보이는 칸이 [수정] 병합에서 살게 + const draft = record && { ...record, type_id: next.type_id, options: back?.options ?? {} }; + if (draft) ctx.structures[index] = draft; + ctx.syncPlacementFields(); + ctx.renderOptionFields(back?.options); + ctx.setSummary(readSummaryItem(back?.summary_item)); + ctx.groupBoxes.set(readGroupItems(back?.group_items)); + ctx.groupBoxes.setShapes(back?.options ?? {}); + ctx.syncFacilityForm(back?.options); + ctx.liveCommit(); + // 반영이 막혀(측점 등) 기록이 그대로면 옛 기록으로 되돌림 + if (record && draft && ctx.structures[index] === draft) ctx.structures[index] = record; + } + + type TypeValues = { + options?: Record; + summary_item?: unknown; + group_items?: unknown; + }; + type TypeCache = Record>; + /** 관 지점은 재계산 스냅으로 누가거리가 살짝 옮겨짐 — 관 매칭 기준 안이면 같은 관. */ + function cacheKeyOf(cache: TypeCache, key: string): string { + if (!key.startsWith("p:")) return key; + const at = Number(key.slice(2)); + const hit = Object.keys(cache).find( + (k) => k.startsWith("p:") && Math.abs(Number(k.slice(2)) - at) < PIPE_MATCH_M, + ); + return hit ?? key; + } + function stashTypeValues(key: string, typeId: string, values: TypeValues): void { + const cache = readState("structure-type-cache", ctx.projectId) ?? {}; + const slot = cacheKeyOf(cache, key); + cache[slot] = { ...cache[slot], [typeId]: values }; + writeState("structure-type-cache", cache, ctx.projectId); + } + function recallTypeValues(key: string, typeId: string): TypeValues | null { + const cache = readState("structure-type-cache", ctx.projectId) ?? {}; + return cache[cacheKeyOf(cache, key)]?.[typeId] ?? null; + } ctx.primary.addEventListener("click", () => void ctx.commit()); ctx.removeButton.addEventListener("click", () => { // 임시 배치 중이면 취소와 같다 — 관을 물리고 추가 직전 상태로(2026-08-18). diff --git a/B06_Section/B06_Section_UI_Section_Columns.ts b/B06_Section/B06_Section_UI_Section_Columns.ts new file mode 100644 index 000000000..36653df8f --- /dev/null +++ b/B06_Section/B06_Section_UI_Section_Columns.ts @@ -0,0 +1,47 @@ +/* ============================================================================= + * B06_Section_UI_Section_Columns.ts + * 횡단 카드 한 줄 카드 수 고르기 — 보기 칸 높이에 첫 줄 + 둘째 줄 절반이 들어가는 + * 가장 적은 카드 수. 카드 폭은 최소~최대 폭 안(그림은 축척 그대로 · 폭만 바뀜). + * ========================================================================== */ + +import { + CROSS_CARD_CHROME, + CROSS_GRID_GAP, + CROSS_GRID_MAX_WIDTH, + CROSS_GRID_MIN_WIDTH, +} from "./B06_Section_UI_Section_Common"; + +export interface CrossColumns { + columns: number; + cardWidth: number; +} + +const widthFor = (columns: number, viewWidth: number): number => + Math.min(CROSS_GRID_MAX_WIDTH, (viewWidth - (columns - 1) * CROSS_GRID_GAP) / columns); + +/** + * `heightsFor(cardWidth, count)` — 그 폭일 때 앞 `count` 장의 카드 자연 높이. + * `viewHeight` 가 0 이하(숨김 · 아직 안 잰 칸)면 가장 큰 카드 쪽을 고른다. + */ +export function pickCrossColumns( + viewWidth: number, + viewHeight: number, + heightsFor: (cardWidth: number, count: number) => number[], +): CrossColumns { + const unit = CROSS_GRID_MIN_WIDTH + CROSS_GRID_GAP; + const most = Math.max(1, Math.floor((viewWidth + CROSS_GRID_GAP) / unit)); + const fewest = Math.min( + most, + Math.max(1, Math.ceil((viewWidth + CROSS_GRID_GAP) / (CROSS_GRID_MAX_WIDTH + CROSS_GRID_GAP))), + ); + for (let columns = fewest; columns < most; columns += 1) { + const cardWidth = widthFor(columns, viewWidth); + if (viewHeight <= 0) return { columns, cardWidth }; + const heights = heightsFor(cardWidth, columns * 2); + const first = Math.max(0, ...heights.slice(0, columns)) + CROSS_CARD_CHROME; + const second = Math.max(0, ...heights.slice(columns)) + CROSS_CARD_CHROME; + const needed = heights.length > columns ? first + CROSS_GRID_GAP + second / 2 : first; + if (needed <= viewHeight) return { columns, cardWidth }; + } + return { columns: most, cardWidth: widthFor(most, viewWidth) }; +} diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index f745e7b63..79f63d0c1 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -35,8 +35,11 @@ export const LONG_WIDTH = 1200; export const LONG_HEIGHT = 220; export const CROSS_WIDTH = 560; export const CROSS_HEIGHT = 250; -// 한 행 맞춤 기준 최소 카드 폭 — 조금 더 넓은 화면 필요(480→560, 약 +17%). -export const CROSS_GRID_MIN_WIDTH = 560; +// 카드 폭 범위 — 한 줄 카드 수는 보기 칸 높이로 정한다(`_Section_Columns`). +export const CROSS_GRID_MIN_WIDTH = 400; +export const CROSS_GRID_MAX_WIDTH = 800; +// 카드에서 그림 높이 밖 몫 — 머리 · 설계 줄 · 꼬리(실측 135). +export const CROSS_CARD_CHROME = 135; export const CROSS_GRID_GAP = 16; // bottom은 측점 라벨 한 줄 몫이다. X축 제목을 그래프 상단으로 올린 뒤 남던 여백을 걷어내, // 종단면도 아래 라벨이 그 밑 유토곡선과의 틈 **가운데**에 오도록 좁혔다(2026-08-02 사용자 지시). diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index b857bea3e..1b5a20121 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -42,9 +42,8 @@ import { CROSS_HEIGHT } from "./B06_Section_UI_Section_Common"; import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu"; import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; +import { pickCrossColumns } from "./B06_Section_UI_Section_Columns"; import { - CROSS_GRID_GAP, - CROSS_GRID_MIN_WIDTH, CROSS_WIDTH, type CrossDesignChange, type DesignChangeHandler, @@ -93,6 +92,8 @@ export function createSectionView( let currentStationInterval: number | undefined; let structureEdit: SectionStructureEdit | null = null; let renderWidth = 0; + let renderHeight = 0; + let drawnColumns = 0; let resizeTimer = 0; // 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신) let cachedStationInterval = 1; @@ -136,6 +137,33 @@ export function createSectionView( return Number.isFinite(width) ? Math.max(0, width) : 0; }; + const contentHeight = (): number => { + const style = getComputedStyle(cards); + const pad = (value: string): number => Number.parseFloat(value) || 0; + const height = cards.clientHeight - pad(style.paddingTop) - pad(style.paddingBottom); + return Number.isFinite(height) ? Math.max(0, height) : 0; + }; + + /** 카드 폭에서 앞 `count` 장의 기하 — 한 줄 카드 수 고르기 · 그리기가 함께 쓴다. */ + const plotBasesFor = (detail: SectionDetailResponse, cardWidth: number, count: number) => + detail.cross_sections + .slice(0, count) + .map((section) => + crossPlotBase( + section, + currentExaggeration, + cardWidth, + effectiveCardHalfWidth(section, stationWidth?.widthFor(section) ?? currentCrossHalfWidth), + designElevationAt(detail.longitudinal.design_profiles, section.chainage_m), + ), + ); + const naturalHeightsOf = (bases: ReadonlyArray): number[] => + bases.map((base) => Math.max(base?.naturalHeight ?? CROSS_HEIGHT, CROSS_HEIGHT)); + const pickColumns = (detail: SectionDetailResponse) => + pickCrossColumns(renderWidth, renderHeight, (cardWidth, count) => + naturalHeightsOf(plotBasesFor(detail, cardWidth, count)), + ); + /** * 선택 카드를 위 칸 안에서 보이게 한다 — `start`로 카드 **상단**(측점 라벨)을 칸 위에 * 붙인다(2026-08-04 사용자 지적). 목록 맨 끝 카드는 스크롤이 바닥에 걸려 그 자리까지 못 @@ -316,14 +344,14 @@ export function createSectionView( grid.addEventListener("click", (event) => { if (!(event.target as HTMLElement).closest(".b06-cross-card")) clearSelection(); }); - const columnCount = Math.max( - 1, - Math.floor((renderWidth + CROSS_GRID_GAP) / (CROSS_GRID_MIN_WIDTH + CROSS_GRID_GAP)), - ); + // 열 수 = 보기 칸 높이에 1.5줄이 드는 가장 적은 수 · 카드 폭 400~800(`_Section_Columns`). + const picked = pickColumns(detail); + const columnCount = picked.columns; + drawnColumns = columnCount; // CSS auto-fill의 자체 열 수와 JS columnCount가 어긋나면 같은 행 높이 그룹핑이 실제 - // 렌더 행과 달라져 카드 내부가 뒤죽박죽된다(N-2-6). 열 수를 JS가 명시해 일치시킨다. - grid.style.gridTemplateColumns = `repeat(${columnCount}, minmax(0, 1fr))`; - cachedCardWidth = (renderWidth - (columnCount - 1) * CROSS_GRID_GAP) / columnCount; + // 렌더 행과 달라져 카드 내부가 뒤죽박죽된다(N-2-6). 열 수 · 폭을 JS가 명시해 일치시킨다. + grid.style.gridTemplateColumns = `repeat(${columnCount}, ${picked.cardWidth}px)`; + cachedCardWidth = picked.cardWidth; cachedRowHeight.clear(); if (detail.cross_sections.length) { // 1차: 각 카드의 자연 높이(250px 바닥 적용) 측정 → 같은 행(columnCount 단위) 최댓값을 행 높이로. @@ -332,18 +360,8 @@ export function createSectionView( // 그것이 진입에서 `draw` 자기 시간의 대부분이었다(2026-09-06 CPU 프로파일). // 반폭도 카드가 실제로 쓰는 값(`effectiveCardHalfWidth`, 자동 줌아웃 포함)으로 맞춰 // 재 둔다 — 카드·행 높이가 어긋나지 않는다. - const plotBases = sections.map((section) => - crossPlotBase( - section, - currentExaggeration, - cachedCardWidth, - effectiveCardHalfWidth(section, stationWidth?.widthFor(section) ?? currentCrossHalfWidth), - designElevationAt(detail.longitudinal.design_profiles, section.chainage_m), - ), - ); - const naturalHeights = plotBases.map((base) => - Math.max(base?.naturalHeight ?? CROSS_HEIGHT, CROSS_HEIGHT), - ); + const plotBases = plotBasesFor(detail, cachedCardWidth, sections.length); + const naturalHeights = naturalHeightsOf(plotBases); // 2차: 행별 최댓값으로 강제 높이를 정해 같은 행 카드를 동일 높이로 렌더한다. for (let start = 0; start < sections.length; start += columnCount) { const rowHeight = Math.max(...naturalHeights.slice(start, start + columnCount)); @@ -401,11 +419,17 @@ export function createSectionView( const resizeObserver = new ResizeObserver(() => { const nextWidth = contentWidth(); - if (!Number.isFinite(nextWidth) || nextWidth <= 0 || Math.abs(nextWidth - renderWidth) < 1) - return; + const nextHeight = contentHeight(); + if (!Number.isFinite(nextWidth) || nextWidth <= 0) return; + const widthMoved = Math.abs(nextWidth - renderWidth) >= 1; + if (!widthMoved && Math.abs(nextHeight - renderHeight) < 1) return; window.clearTimeout(resizeTimer); resizeTimer = window.setTimeout(() => { renderWidth = nextWidth; + renderHeight = nextHeight; + // 높이만 바뀌면(아래 패널 끌기 · 접기) 한 줄 카드 수가 바뀔 때만 다시 그린다. + if (!widthMoved && currentDetail && pickColumns(currentDetail).columns === drawnColumns) + return; draw(); }, 150); }); @@ -442,6 +466,7 @@ export function createSectionView( // 진입 시 측점을 자동으로 고르지 않는다(2026-08-02 사용자 지시) — 0측점이 선택된 채로 // 시작하면 사용자가 고르지도 않은 카드가 강조돼 있고 유토곡선 말풍선도 떠 있다. renderWidth = contentWidth(); + renderHeight = contentHeight(); draw(); if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(cards)); }, diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 9782f31dc..e2372cce0 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -226,3 +226,36 @@ B05에서 3D 영역의 상단 버튼들 중 원근으로, 직교로 버튼의 (10-6 사용자) 횡단 카드 폭과 종단 하단 패널(테이블, 유토곡선, 배수유역도 포함) 둘다 너의 제안대로 반영해줘. 반영해주면 내가 직접 검증하겠음 + +(10-6 사용자 · 배관 기본값) +관 컨테이너 관경 배수유역에 의해 자동설정 값, 사용자에 의해 변경 가능 + 관길이 횡단도의 관 보호공의 위치에 의해 결정, 사용자 변경 불가 +관 보호공(공통) 구조 집수정 +관 보호공(유입)_집수정 구조 집수정 일 경우 + 형태 L형 + 높이 1.5 +관 보호공(유입)_흙막이 구조 흙 막이 일 경우 + 형태 돌 쌓기(찰) + 길이 10 + 높이 1.5 + 기준측점 전 5 + 기준측점 후 5 +관 보호공(유출)_흙막이 구조 흙막이 일 경우(구조 선택지 하나밖에 없음) + 형태 돌 쌓기(찰) + 길이 10 + 높이 1.5 + 기준측점 전 5 + 기준측점 후 5 + 추가 흙막이 단 0 + 연동 TRUE(활성화 될 경우) + 경사 TRUE(활성화 될 경우) +추가 흑막이 구조 흙막이 일 경우(구조 선택지 하나밖에 없음) + 형태 돌 쌓기(찰) + 길이 10 + 높이 1.5 + 기준측점 전 5 + 기준측점 후 5 + 연동 TRUE(활성화 될 경우) + 경사 TRUE(활성화 될 경우) +* 사면안정 구조물 군의 흙막이도 동일함, 일원화 +* 연동과 경사는 공간이 부족하여 줄바꿈처리됨. 글자를 조금 줄여서 줄바꿈 안되게 반영하거나 글자 주변 여유를 조금 줄일것 diff --git a/resources/tester/ui_checks/54-32_군종류_캐시.js b/resources/tester/ui_checks/54-32_군종류_캐시.js new file mode 100644 index 000000000..84a3d5394 --- /dev/null +++ b/resources/tester/ui_checks/54-32_군종류_캐시.js @@ -0,0 +1,152 @@ +/* 54-32 고른 구조물에서 군 바꿈 = 새로 놓기 · 같은 군 종류 바꿈 = 새 종류 기본값 + 종류별 캐시 + * 시작 화면: B06 횡단설계(#/b06-section) · 검증 프로젝트(sub4) · 로그인 + * 부르는 법: 단계마다 `window.__m5432 = "단계";` 뒤 이 파일 본문을 eval + * a (B06) 돌쌓기(찰) 1: 칸 고침 → 다른 종류 → 돌아옴 = 고친 값 · 군 바꿈 = 고름 0 + * 4+5 배관 1: 관경 고침 → BOX(칸 고침) → 배관 = 고친 관경 · 군 바꿈 = 고름 0 · 캐시 있음 + * b (B05) 배관 → BOX = B06 에서 고친 BOX 값(캐시 공유) → 배관 · 관경 · 돌쌓기 칸 원래 값으로 + * c [저장] → 캐시 비움 · 배관 → BOX = BOX 기본 · → 배관 = 저장 관경 · 다시 [저장] + * 결과는 sessionStorage `__5432_out_{단계}` · 기대: fails 0 + */ +(async () => { + const MODE = window.__m5432 || "a"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const pid = localStorage.getItem("frd_current_project_id"); + const t = (e) => e.innerText.replace(/\s+/g, " ").trim(); + const fails = []; + const out = {}; + const fail = (m) => fails.push(m); + const sec = () => document.querySelector(".b05-structure-section"); + const grid = () => sec().querySelectorAll(".b05-structure__grid select"); + const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const row = (head) => rows().find((li) => t(li).startsWith(head)); + const picked = () => rows().filter((li) => li.classList.contains("is-selected")).length; + const read = (name) => { + const raw = sessionStorage.getItem(`aislo:draft:${name}:${pid}`); + const v = raw ? JSON.parse(raw) : null; + return v?.value ?? v; + }; + const pipe = () => (read("pipes") ?? []).find((p) => Math.abs(p.chainage_m - 85.05) < 0.51); + const fire = (el, value) => { + el.value = value; + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + const pick = async (head) => { + for (let k = 0; k < 40 && !row(head); k++) await w(300); + if (!row(head)?.classList.contains("is-selected")) row(head)?.click(); + await w(2000); + }; + const setType = async (id) => (fire(grid()[1], id), await w(2500)); + const setGroup = async (g) => (fire(grid()[0], g), await w(2000)); + /** 종류 칸 아래 첫 고를 수 있는 선택 칸(관경 · BOX 폭 등) */ + const firstSelect = () => + [...sec().querySelectorAll("select")] + .slice(2) + .find( + (s) => s.offsetParent && !s.disabled && [...s.options].filter((o) => o.value).length > 1, + ); + const other = (s) => [...s.options].map((o) => o.value).find((v) => v && v !== s.value); + const firstNumber = () => + [...sec().querySelectorAll("input")].find( + (i) => + i.offsetParent && + !i.disabled && + i.value !== "" && + !isNaN(Number(i.value)) && + !/측점/.test(t(i.closest("label") ?? i)), + ); + const S = "4+0~4+10 돌쌓기(찰)"; + const P = "4+5 배관"; + const ck = () => sessionStorage.getItem(`aislo:draft:structure-type-cache:${pid}`); + const memo = JSON.parse(sessionStorage.getItem("__5432_memo") || "{}"); + + if (MODE === "a") { + await pick(S); + const t0 = grid()[1].value; + const num = firstNumber(); + memo.sLabel = t(num.closest("label")); + memo.s0 = num.value; + const s1 = String(Number(num.value) + 0.5); + fire(num, s1); + await w(1200); + const t1 = [...grid()[1].options].map((o) => o.value).find((v) => v && v !== t0); + await setType(t1); + out.afterSwitch = { + type: grid()[1].value, + picked: picked(), + primary: t(sec().querySelector("button")), + }; + if (picked() !== 1) fail(`종류 바꿈 뒤 고름 ${picked()}`); + await setType(t0); + const back = firstNumber(); + out.structBack = back?.value; + if (Number(back?.value) !== Number(s1)) fail(`돌쌓기 되살림 ${back?.value} ≠ ${s1}`); + fire(back, memo.s0); + await w(1200); + await setGroup("B"); + out.structGroup = { picked: picked(), type: grid()[1].value, row: !!row(S) }; + if (picked() !== 0 || grid()[1].value !== "") + fail(`돌쌓기 군 바꿈 ${JSON.stringify(out.structGroup)}`); + + await pick(P); + const d = firstSelect(); + memo.p0 = d.value; + memo.p1 = other(d); + fire(d, memo.p1); + await w(2500); + await setType("box_culvert"); + out.boxType = pipe()?.facility; + if (pipe()?.facility !== "box_culvert") fail(`BOX 초안 ${pipe()?.facility}`); + const b = firstSelect(); + memo.bDefault = b.value; + memo.b1 = other(b); + fire(b, memo.b1); + await w(2500); + await setType("pipe"); + out.pipeBack = firstSelect()?.value; + if (firstSelect()?.value !== memo.p1) fail(`배관 되살림 ${firstSelect()?.value} ≠ ${memo.p1}`); + await setGroup("C"); + out.pipeGroup = { picked: picked(), type: grid()[1].value, facility: pipe()?.facility }; + if (picked() !== 0 || pipe()?.facility !== "pipe") + fail(`배관 군 바꿈 ${JSON.stringify(out.pipeGroup)}`); + out.cache = !!ck(); + if (!ck()) fail("캐시 없음"); + } + + if (MODE === "b") { + await pick(P); + await setType("box_culvert"); + out.boxFromCache = firstSelect()?.value; + if (firstSelect()?.value !== memo.b1) fail(`B05 BOX 캐시 ${firstSelect()?.value} ≠ ${memo.b1}`); + await setType("pipe"); + out.pipeFromCache = firstSelect()?.value; + if (firstSelect()?.value !== memo.p1) + fail(`B05 배관 캐시 ${firstSelect()?.value} ≠ ${memo.p1}`); + fire(firstSelect(), memo.p0); + await w(2500); + out.pipeRestored = pipe()?.options?.diameter_mm ?? firstSelect()?.value; + } + + if (MODE === "c") { + const save = async () => { + [...document.querySelectorAll("button")].find((x) => t(x) === "저장").click(); + await w(15000); + }; + await save(); + out.cacheAfterSave = ck(); + if (ck()) fail("저장 뒤 캐시 남음"); + await pick(P); + await setType("box_culvert"); + out.boxAfterSave = firstSelect()?.value; + if (firstSelect()?.value !== memo.bDefault) + fail(`저장 뒤 BOX ${firstSelect()?.value} ≠ 기본 ${memo.bDefault}`); + await setType("pipe"); + out.pipeAfterSave = firstSelect()?.value; + if (firstSelect()?.value !== memo.p0) fail(`저장 뒤 배관 ${firstSelect()?.value} ≠ ${memo.p0}`); + await save(); + out.cacheEnd = ck(); + } + + sessionStorage.setItem("__5432_memo", JSON.stringify(memo)); + sessionStorage.setItem(`__5432_out_${MODE}`, JSON.stringify({ fails, out, memo })); +})();