diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 9c05c761..53711a03 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -107,6 +107,8 @@ export const STATE_REGISTRY = { culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` }, /** 배수관 이동(측점 옮김) 예약. */ culvertmove: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertmove:${p}:${r}` }, + /** 배수관 추가·삭제 예약(2026-09-12) — B06 종단·목록에서 넣고 뺀 관. */ + culvertedit: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertedit:${p}:${r}` }, /** 암 경계선 오프셋(측점별). */ rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` }, /** 측점별 암 절토 경사비(1:n 의 n) — 카드에서 넣은 사용자 값(2026-09-07). diff --git a/B06_Section/B06_Section_Api_Culvert_Options.ts b/B06_Section/B06_Section_Api_Culvert_Options.ts index 130274e0..eae5d769 100644 --- a/B06_Section/B06_Section_Api_Culvert_Options.ts +++ b/B06_Section/B06_Section_Api_Culvert_Options.ts @@ -31,6 +31,11 @@ export interface CulvertOptionWriter { /** 기준 측점 이동을 예약한다(2026-08-29 사용자: B06에서도 옮길 수 있어야 한다). * 세부 배수유역 재분할은 저장 시 서버가 관 목록을 다시 받아 처리한다. */ queueMove: (fromChainageM: number, toChainageM: number) => void; + /** 관(계곡 통과 시설) 추가를 예약한다 — B05·B06 은 한 페이지라 넣는 자리도 같아야 한다 + * (2026-09-12 사용자). 이동과 같은 규칙으로 세션에만 쌓고 [저장]·[확정]에서 나간다. */ + queueAdd: (chainageM: number, facility?: string) => void; + /** 관 삭제를 예약한다. 추가해 둔 것을 다시 지우면 예약만 걷어 낸다. */ + queueRemove: (chainageM: number) => void; /** 예약분을 즉시 내보낸다([저장]·[확정]에서만 부른다). */ flush: () => Promise; } @@ -75,16 +80,59 @@ function readMoves(sessionKey: string | null): PendingMoves { } } +/** 예약된 추가·삭제 — 이동과 같은 세션 칸에 함께 담는다(키 하나로 읽고 쓴다). */ +interface PendingEdits { + /** 넣을 관 — 누가거리(m)와 시설 종류(기본 배관). */ + adds: Array<{ chainage_m: number; facility?: string }>; + /** 지울 관 — 측점 키(누가거리 2자리). */ + removes: string[]; +} + +const EMPTY_EDITS: PendingEdits = { adds: [], removes: [] }; + +function readEdits(sessionKey: string | null): PendingEdits { + if (!sessionKey) return { ...EMPTY_EDITS, adds: [], removes: [] }; + try { + const raw = window.sessionStorage.getItem(sessionKey); + const parsed = raw ? (JSON.parse(raw) as Partial) : null; + return { + adds: Array.isArray(parsed?.adds) ? parsed.adds : [], + removes: Array.isArray(parsed?.removes) ? parsed.removes : [], + }; + } catch { + return { adds: [], removes: [] }; + } +} + +function writeEdits(sessionKey: string | null, edits: PendingEdits): void { + if (!sessionKey) return; + try { + if (!edits.adds.length && !edits.removes.length) window.sessionStorage.removeItem(sessionKey); + else window.sessionStorage.setItem(sessionKey, JSON.stringify(edits)); + } catch { + /* 세션 저장 실패는 무시 — 값은 화면 캐시에 남아 있다. */ + } +} + export async function flushCulvertOptions( projectId: string | null, sessionKey: string | null, moveKey: string | null = null, + editKey: string | null = null, ): Promise { const pending = readPending(sessionKey); const moves = readMoves(moveKey); - if (!projectId || (Object.keys(pending).length === 0 && Object.keys(moves).length === 0)) return; + const edits = readEdits(editKey); + if ( + !projectId || + (Object.keys(pending).length === 0 && + Object.keys(moves).length === 0 && + !edits.adds.length && + !edits.removes.length) + ) + return; const current = await fetchDetailPipePoints(projectId); - let touched = false; + let touched = edits.adds.length > 0 || edits.removes.length > 0; const points: DetailPipeInput[] = current.pipe_points.map((point) => { // 좌표는 서버가 다시 계산하므로 싣지 않는다(DetailPipeInput 규약). const { lonlat: _lonlat, ...input } = point; @@ -100,9 +148,23 @@ export async function flushCulvertOptions( if (movedTo !== undefined) next.chainage_m = movedTo; return next; }); - if (touched) await saveDetailPipePoints(projectId, points); + // 지울 것을 걷어 내고 넣을 것을 붙인다 — 서버는 이 목록을 그대로 정본으로 삼는다. + const removed = new Set(edits.removes); + const next = points.filter((point) => !removed.has(keyOf(point.chainage_m))); + for (const add of edits.adds) { + if (next.some((point) => Math.abs(point.chainage_m - add.chainage_m) < 0.005)) continue; + next.push({ + chainage_m: add.chainage_m, + // 사람이 넣은 자리 — 세류 교차·간격 규칙이 만든 것과 구분한다. + source: "user", + ...(add.facility ? { facility: add.facility as DetailPipeInput["facility"] } : {}), + }); + } + next.sort((left, right) => left.chainage_m - right.chainage_m); + if (touched) await saveDetailPipePoints(projectId, next); writePending(sessionKey, {}); writePending(moveKey, {}); + writeEdits(editKey, { adds: [], removes: [] }); } /** @@ -115,6 +177,8 @@ export function createCulvertOptionWriter( onError?: (message: string) => void, /** 이동 예약 세션 키 — 없으면 이동은 메모리에도 남지 않는다. */ moveKey: () => string | null = () => null, + /** 추가·삭제 예약 세션 키 — 없으면 추가·삭제를 받지 않는다. */ + editKey: () => string | null = () => null, ): CulvertOptionWriter { return { queue(chainageM, patch) { @@ -134,9 +198,32 @@ export function createCulvertOptionWriter( moves[origin] = toChainageM; window.sessionStorage.setItem(key, JSON.stringify(moves)); }, + queueAdd(chainageM, facility) { + const key = editKey(); + if (!key) return; + const edits = readEdits(key); + const at = Number(chainageM.toFixed(2)); + // 같은 자리를 지웠다가 다시 넣으면 삭제 예약만 걷어 낸다(정본은 그대로). + const removedAt = edits.removes.indexOf(keyOf(at)); + if (removedAt >= 0) edits.removes.splice(removedAt, 1); + else if (!edits.adds.some((entry) => Math.abs(entry.chainage_m - at) < 0.005)) + edits.adds.push({ chainage_m: at, ...(facility ? { facility } : {}) }); + writeEdits(key, edits); + }, + queueRemove(chainageM) { + const key = editKey(); + if (!key) return; + const edits = readEdits(key); + const at = Number(chainageM.toFixed(2)); + // 아직 정본에 없는(이번에 넣은) 관이면 추가 예약만 거둔다. + const addedAt = edits.adds.findIndex((entry) => Math.abs(entry.chainage_m - at) < 0.005); + if (addedAt >= 0) edits.adds.splice(addedAt, 1); + else if (!edits.removes.includes(keyOf(at))) edits.removes.push(keyOf(at)); + writeEdits(key, edits); + }, async flush() { try { - await flushCulvertOptions(projectId(), sessionKey(), moveKey()); + await flushCulvertOptions(projectId(), sessionKey(), moveKey(), editKey()); } catch (error) { onError?.(error instanceof Error ? error.message : "배수관 구간값 저장 실패"); throw error; diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 178b74b8..5b9d296e 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -203,6 +203,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch), movePipe: (fromChainageM, toChainageM) => stationControls.queueCulvertMove(fromChainageM, toChainageM), + addPipe: (chainageM, facility) => stationControls.queueCulvertAdd(chainageM, facility), + removePipe: (chainageM) => stationControls.queueCulvertRemove(chainageM), }); const dockDivider = document.createElement("hr"); @@ -483,6 +485,17 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { ); // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); + // 종단 그래프 우클릭 — B05 와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자: B05·B06 은 한 + // 페이지라 같은 자리에서 되어야 한다). 어느 길로 들어와도 좌측 「구조물 배치」와 + // 같은 함수를 타므로 목록·폼·알약이 함께 선다. + sectionView.setStructureEdit({ + addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM), + removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM), + addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId), + removeStructure: (structureId) => { + structuresPanel.removeStructureById(structureId); + }, + }); // 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리). const pipeOptionsContext: PipeOptionsContext = { detail: () => sectionDetail, diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 095e6be5..e777ea9e 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -45,6 +45,7 @@ export interface StationControlDeps { | "extraspan" | "culvertopt" | "culvertmove" + | "culvertedit" | "revetlink" | "fordadjust" | "boxadjust", @@ -96,6 +97,10 @@ export interface StationControls { queueCulvertOptions: (chainageM: number, patch: Record) => void; /** 기준 측점 이동을 예약한다 — B06 구조물 배치 폼의 측점 칸이 쓴다(2026-08-29). */ queueCulvertMove: (fromChainageM: number, toChainageM: number) => void; + /** 관 추가를 예약한다 — B06 종단 우클릭·목록이 쓴다(2026-09-12 B05·B06 일원화). */ + queueCulvertAdd: (chainageM: number, facility?: string) => void; + /** 관 삭제를 예약한다. */ + queueCulvertRemove: (chainageM: number) => void; load: () => void; /** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */ applyGlobalWidth: (requested: number | undefined, chainages: number[]) => void; @@ -432,6 +437,7 @@ export function createStationControls(deps: StationControlDeps): StationControls () => deps.sessionKey("culvertopt"), deps.onSaveError, () => deps.sessionKey("culvertmove"), + () => deps.sessionKey("culvertedit"), ); const linkSession = createLinkFlagSession(() => deps.sessionKey("revetlink")); const linkDetached = linkSession.detached; @@ -619,6 +625,8 @@ export function createStationControls(deps: StationControlDeps): StationControls queueCulvertOptions: (chainageM, patch) => culvertOptions.queue(chainageM, patch), queueCulvertMove: (fromChainageM, toChainageM) => culvertOptions.queueMove(fromChainageM, toChainageM), + queueCulvertAdd: (chainageM, facility) => culvertOptions.queueAdd(chainageM, facility), + queueCulvertRemove: (chainageM) => culvertOptions.queueRemove(chainageM), load: () => { loadStationWidths(); loadRevetShifts(); diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index b3bb3fd4..1a824da0 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -39,8 +39,13 @@ import { import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; -const PIPE_ADD_GUIDE = "계곡 통과 시설의 추가·삭제는 B05(종단) 화면에서 합니다."; +const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요."; const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다."; +/** B05·B06 은 한 페이지라 넣고 빼는 자리도 같아야 한다(2026-09-12 사용자). 재분할은 저장 뒤. */ +const PIPE_ADD_NOTICE = + "계곡 통과 시설을 넣었습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다."; +const PIPE_REMOVE_NOTICE = + "계곡 통과 시설을 뺐습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다."; /** 고른 것 없이 폼만 만졌을 때 — 값이 어디로도 가지 않으므로 이유를 알린다. */ const PIPE_PICK_GUIDE = "먼저 횡단도나 목록에서 구조물을 고르세요 — 고른 것에만 값이 반영됩니다."; const PIPE_MOVE_NOTICE = @@ -75,6 +80,10 @@ export interface B06StructuresPanelDeps { /** 기준 측점 이동을 예약한다 — 세부 배수유역 재분할은 [저장]·[확정] 때 서버가 * 관 목록을 다시 받아 처리한다(2026-08-29 사용자: B06에서도 옮길 수 있어야 한다). */ movePipe?: (fromChainageM: number, toChainageM: number) => void; + /** 관(계곡 통과 시설)을 넣는다 — 이동과 같은 예약 경로(2026-09-12 B05·B06 일원화). */ + addPipe?: (chainageM: number, facility?: string) => void; + /** 관을 뺀다 — 같은 예약 경로. */ + removePipe?: (chainageM: number) => void; /** 구조물 목록이 바뀌었다 — 벽(C군)은 횡단 제원으로 얹혀 **면적까지 달라지므로** * 화면이 횡단을 다시 받아 그려야 한다(2026-09-06 사용자 확정). */ onStructuresChanged?: () => void; @@ -97,6 +106,11 @@ export interface B06StructuresPanel { showAtChainage: (chainageM: number | null) => void; /** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */ currentChainage: () => number | null; + /** 종단 그래프 우클릭이 쓰는 길 — 좌측 폼·목록과 **같은 함수**를 탄다(2026-09-12). */ + addStructureAt: (chainageM: number, typeId: string) => void; + removeStructureById: (structureId: string) => boolean; + addPipeAt: (chainageM: number) => void; + removePipeAt: (chainageM: number) => void; /** 유입구 "구조"에 합쳐진 B06 유입측 형식 — 조정창 값과 맞춘다(2026-08-29 지시 5). */ facility: { setInletStructure: (value: "auto" | "revet" | "I" | "L" | "U") => void; @@ -248,7 +262,27 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc }, getInterval: deps.stationInterval, onReveal: () => deps.reveal?.(), - onPipeAdd: () => showToast(PIPE_ADD_GUIDE, "error"), + // 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다. + onPipeAdd: (chainageM, attributes) => { + if (!deps.addPipe) { + showToast(PIPE_ADD_GUIDE, "error"); + return; + } + const at = Number(chainageM.toFixed(2)); + if (pipeFacilities.some((entry) => Math.abs(entry.chainage_m - at) < PIPE_MATCH_M)) { + showToast("그 자리에는 계곡 통과 시설이 이미 있습니다.", "error"); + return; + } + const facility = attributes.facility ?? "pipe"; + deps.addPipe(at, facility); + pipeFacilities.push({ chainage_m: at, facility, options: attributes.options ?? {} }); + pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m); + currentChainageM = at; + section.setPipeFacilities(pipeFacilities); + pushMarks(); + deps.onStructuresChanged?.(); + showToast(PIPE_ADD_NOTICE, "success"); + }, // 값 수정은 B06에서도 받는다 — 조정창 구간값과 같은 저장 경로(캐시 예약 → // [저장]·[확정])로 보낸다. 기준점 이동만 배수유역 재분할이 걸려 B05 몫이다. onPipeUpdate: (fromChainageM, toChainageM, attributes) => { @@ -289,7 +323,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc if (hit) hit.options = { ...(hit.options ?? {}), ...patch }; section.setPipeFacilities(pipeFacilities); }, - onPipeRemove: () => showToast(PIPE_ADD_GUIDE, "error"), + onPipeRemove: (chainageM) => removePipeAt(chainageM), onPipeSelect: (chainageM) => { // 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다. writeStructurePick(deps.projectId, chainageM); @@ -400,11 +434,35 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc section.setPipeFacilities(pipeFacilities); } + /** 관 빼기 — 목록·폼·종단 우클릭이 같은 길을 탄다. 정본은 [저장]·[확정]에서 나간다. */ + function removePipeAt(chainageM: number): void { + if (!deps.removePipe) { + showToast(PIPE_ADD_GUIDE, "error"); + return; + } + const at = Number(chainageM.toFixed(2)); + deps.removePipe(at); + pipeFacilities = pipeFacilities.filter( + (entry) => Math.abs(entry.chainage_m - at) >= PIPE_MATCH_M, + ); + if (currentChainageM !== null && Math.abs(currentChainageM - at) < PIPE_MATCH_M) + currentChainageM = null; + section.setPipeFacilities(pipeFacilities); + pushMarks(); + deps.onStructuresChanged?.(); + showToast(PIPE_REMOVE_NOTICE, "success"); + } + return { root: section.root, listRoot: section.listRoot, load, currentChainage: () => currentChainageM, + // 종단 우클릭 → 좌측 폼·목록과 같은 함수. 폼이 열리고 목록·알약도 함께 선다. + addStructureAt: (chainageM, typeId) => section.addAt(chainageM, typeId), + removeStructureById: (structureId) => section.removeById(structureId), + addPipeAt: (chainageM) => section.addAt(chainageM, "pipe"), + removePipeAt: (chainageM) => removePipeAt(chainageM), facility: { setInletStructure: (value) => section.facility.setInletStructure(value), onInletStructureChange: (handler) => section.facility.onInletStructureChange(handler), diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 0d5111e9..d5b940e1 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -50,6 +50,10 @@ import { buildStructureLane, STRUCTURE_LANE_HEIGHT_PX, } from "../B05_Profile/B05_Profile_UI_Structures_Marks"; +import { + mountSectionStructureMenu, + type SectionStructureEdit, +} from "./B06_Section_UI_Section_View_Menu"; import { structureAnchorM, type StructureInstance, @@ -131,6 +135,9 @@ export interface SectionViewController { structures: ReadonlyArray, types: ReadonlyArray, ) => void; + /** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길 — 없으면 메뉴가 안 뜬다 + * (2026-09-12 B05·B06 일원화). */ + setStructureEdit: (edit: SectionStructureEdit | null) => void; dispose: () => void; } @@ -163,6 +170,7 @@ export function createSectionView( let currentNaturalSpoilSlope: number | undefined; let markStructures: ReadonlyArray = []; let markTypes: ReadonlyArray = []; + let structureEdit: SectionStructureEdit | null = null; let renderWidth = 0; let resizeTimer = 0; let panelResizeTimer = 0; @@ -554,6 +562,18 @@ export function createSectionView( } chartWrap.replaceChildren(...nodes); + // 종단 그래프 우클릭 — B05 와 같은 메뉴다(가까운 구조물이 있으면 삭제, 없으면 + // 구조물군 → 종류 2단 추가). 그래프를 다시 그릴 때마다 붙인다(상태를 안 남긴다). + if (structureEdit) { + mountSectionStructureMenu(chartWrap, { + structures: markStructures, + types: markTypes, + x: chart.toX, + chainageAt: chart.toChainage, + maxChainageM: chart.maxChainageM, + edit: structureEdit, + }); + } // 유토곡선 그래프는 B06 에서 **그리지 않는다**(2026-09-06 사용자 지시) — 자리를 많이 // 먹는데 정작 필요한 값은 마지막 지점 누가토량 하나다. 곡선은 B05 유토곡선 패널에서 // 펼쳐 본다. 여기서는 같은 계산으로 값만 내 좌측 상단 배지에 올린다(기준: 횡단). @@ -753,6 +773,10 @@ export function createSectionView( setStationSelectListener(listener) { stationSelectListener = listener; }, + setStructureEdit(edit) { + structureEdit = edit; + drawPanel(); + }, setStructureMarks(structures, types) { markStructures = structures; markTypes = types; diff --git a/B06_Section/B06_Section_UI_Section_View_Menu.ts b/B06_Section/B06_Section_UI_Section_View_Menu.ts new file mode 100644 index 00000000..6a0c1a24 --- /dev/null +++ b/B06_Section/B06_Section_UI_Section_View_Menu.ts @@ -0,0 +1,83 @@ +/* ============================================================================= + * B06_Section_UI_Section_View_Menu.ts + * B06 종단 그래프 위 **우클릭 메뉴** — B05 와 같은 부품(`mountStructureMenu`)을 그대로 쓴다. + * + * 왜 여기서도 여나(2026-09-12 사용자) — B05·B06 은 한 페이지다. 같은 그래프를 보면서 + * 한쪽에서만 넣고 뺄 수 있으면 사용자가 화면을 오가야 하고 직관도 깨진다. + * + * 정본에 바로 쓰지 않는다 — 추가·삭제·이동은 **세션에 예약**하고 [저장]·[확정]에서 + * 관 정본(`pipe_points.json`)으로 나간다(CLAUDE.md 5장). 세부 배수유역 재분할은 그때 + * 서버가 관 목록을 다시 받아 처리한다. + * ========================================================================== */ + +import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; +import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures"; +import type { IrregularStation } from "../B05_Profile/B05_Profile_UI_IrregularStations"; +import { mountStructureMenu } from "../B05_Profile/B05_Profile_UI_Profile_Structures"; + +/** 종단 그래프에서 구조물을 넣고 빼는 길 — 페이지가 물려 준다. 없으면 메뉴를 안 붙인다. */ +export interface SectionStructureEdit { + /** 관(계곡 통과 시설)을 그 자리에 넣는다. */ + addPipe: (chainageM: number) => void; + /** 관을 뺀다. */ + removePipe: (chainageM: number) => void; + /** 레지스트리 종류를 골라 구조물을 넣는다(좌측 「구조물 배치」와 같은 2단 메뉴). */ + addStructureType: (chainageM: number, typeId: string) => void; + /** 구조물(관 아님)을 뺀다. */ + removeStructure: (structureId: string) => void; +} + +/** 알약·우클릭이 쓰는 「관인가」 판정 — 관은 정본이 달라 지우는 길도 다르다. */ +export function isPipeMark(structure: StructureInstance): boolean { + return String(structure.structure_id ?? "").startsWith("pipe-"); +} + +/** + * 종단 그래프 칸에 우클릭 메뉴를 붙인다. 그래프는 그릴 때마다 새로 만들어지므로 이 + * 함수도 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다(B05 와 같은 규칙). + */ +export function mountSectionStructureMenu( + host: HTMLElement, + input: { + structures: ReadonlyArray; + types: ReadonlyArray; + x: (chainageM: number) => number; + chainageAt: (px: number) => number; + maxChainageM: number; + edit: SectionStructureEdit; + }, +): void { + // 메뉴가 쓰는 최소 정보만 옮겨 담는다 — B06 에는 B05 의 비정규 측점 목록이 없다. + const stations: IrregularStation[] = input.structures.map((structure) => { + const chainage = structureAnchorM(structure); + const pipe = isPipeMark(structure); + return { + id: String(structure.structure_id), + station: 0, + remainder: 0, + chainage_m: chainage, + structure: + input.types.find((type) => type.type_id === structure.type_id)?.name ?? + String(structure.type_id), + origin: pipe ? "pipe" : "user", + } as IrregularStation; + }); + + mountStructureMenu(host, { + stations, + x: input.x, + chainageAt: input.chainageAt, + maxChainageM: input.maxChainageM, + onRemove: (station) => { + if (station.origin === "pipe") input.edit.removePipe(station.chainage_m); + else input.edit.removeStructure(station.id); + }, + onAddPipe: (chainageM) => input.edit.addPipe(chainageM), + structureTypes: input.types.map((type) => ({ + type_id: type.type_id, + group: type.group, + name: type.name, + })), + onAddStructureType: (chainageM, typeId) => input.edit.addStructureType(chainageM, typeId), + }); +} diff --git a/resources/tester/test_b06_culvert_edit_queue.py b/resources/tester/test_b06_culvert_edit_queue.py new file mode 100644 index 00000000..0bca3917 --- /dev/null +++ b/resources/tester/test_b06_culvert_edit_queue.py @@ -0,0 +1,135 @@ +"""B06 종단에서 넣고 뺀 **계곡 통과 시설**이 세션에 제대로 쌓이는지(2026-09-12 사용자 지시). + +B05·B06 은 한 페이지이므로 관 추가·삭제도 양쪽에서 되어야 한다. 다만 정본에 바로 쓰지 +않고 **세션에 예약**했다가 [저장]·[확정]에서 한 번에 내보낸다(CLAUDE.md 5장). 넣었다 바로 +빼면 예약만 걷어 내야 하고, 뺐다 다시 넣어도 마찬가지다 — 그러지 않으면 저장 때 있지도 +않은 관을 지우거나 같은 자리에 둘이 선다. + +실제 화면 코드를 그대로 컴파일해 Node 로 돌린다(다단 하한 시험과 같은 방식). +""" + +import json +import subprocess +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +MODULE = PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Culvert_Options.ts" + +_RUNNER = """ +const { writeFileSync } = require("node:fs"); +const Module = require("node:module"); + +// 화면 코드가 번들러 별칭(`@config/…`)을 쓴다 — Node 에는 없으니 빈 껍데기로 돌려준다. +// 이 시험이 보는 것은 세션 예약 로직뿐이라 값은 안 쓴다. +const load = Module._load; +Module._load = function (request, parent, isMain) { + if (request.startsWith("@config/") || request.startsWith("@util/") || request.startsWith("@ui/")) + return new Proxy({}, { get: () => () => undefined }); + return load.call(this, request, parent, isMain); +}; + +// 세션 저장소 흉내 — 화면 코드는 window.sessionStorage 만 쓴다. +const store = new Map(); +global.window = { + sessionStorage: { + getItem: (key) => (store.has(key) ? store.get(key) : null), + setItem: (key, value) => store.set(key, String(value)), + removeItem: (key) => store.delete(key), + }, +}; + +const { createCulvertOptionWriter } = require(process.argv[3]); +const EDIT_KEY = "edit"; +const writer = createCulvertOptionWriter( + () => "p1", + () => "opt", + undefined, + () => "move", + () => EDIT_KEY, +); +const read = () => JSON.parse(store.get(EDIT_KEY) ?? '{"adds":[],"removes":[]}'); +const steps = {}; + +writer.queueAdd(120, "pipe"); +writer.queueAdd(140); +steps.afterTwoAdds = read(); + +// 방금 넣은 자리를 다시 빼면 **추가 예약만** 사라진다(정본에는 아직 없다). +writer.queueRemove(140); +steps.afterUndoAdd = read(); + +// 정본에 있던 관을 빼면 삭제 예약으로 남는다. +writer.queueRemove(200); +steps.afterRemove = read(); + +// 뺀 자리를 다시 넣으면 삭제 예약만 걷어 낸다(새로 만들지 않는다). +writer.queueAdd(200); +steps.afterUndoRemove = read(); + +// 같은 자리를 두 번 넣어도 하나만 남는다. +writer.queueAdd(120); +steps.afterDuplicateAdd = read(); + +writeFileSync(process.argv[2], JSON.stringify(steps)); +""" + + +def _run(tmp_path: Path) -> dict: + out = tmp_path / "out" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + "--ignoreConfig", + "--target", + "es2022", + "--module", + "commonjs", + "--skipLibCheck", + "--outDir", + str(out), + str(MODULE), + ], + cwd=str(PROJECT_ROOT), + check=False, + capture_output=True, + ) + compiled = next(out.rglob("B06_Section_Api_Culvert_Options.js"), None) + assert compiled is not None, "화면 코드가 JS 로 안 나옴 — tsc 실패" + (out / "runner.cjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "result.json" + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(out / "runner.cjs"), str(result), str(compiled)], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_추가와_삭제_예약이_서로를_걷어_낸다(tmp_path: Path) -> None: + steps = _run(tmp_path) + + # 넣은 둘이 그대로 쌓인다. + assert [entry["chainage_m"] for entry in steps["afterTwoAdds"]["adds"]] == [120.0, 140.0] + assert steps["afterTwoAdds"]["adds"][0]["facility"] == "pipe" + assert steps["afterTwoAdds"]["removes"] == [] + + # 방금 넣은 자리를 빼면 추가 예약만 사라지고 삭제 예약은 안 생긴다. + assert [entry["chainage_m"] for entry in steps["afterUndoAdd"]["adds"]] == [120.0] + assert steps["afterUndoAdd"]["removes"] == [] + + # 정본에 있던 관은 삭제 예약으로 남는다. + assert steps["afterRemove"]["removes"] == ["200.00"] + + # 뺀 자리를 다시 넣으면 삭제 예약만 걷힌다 — 새 관을 만들지 않는다. + assert steps["afterUndoRemove"]["removes"] == [] + assert [entry["chainage_m"] for entry in steps["afterUndoRemove"]["adds"]] == [120.0] + + # 같은 자리를 두 번 넣어도 하나만 남는다. + assert [entry["chainage_m"] for entry in steps["afterDuplicateAdd"]["adds"]] == [120.0]