feat(b06): 종단 그래프 우클릭으로 구조물·계곡 통과 시설을 넣고 빼게 함
B05 와 B06 은 한 페이지인데 구조물을 넣고 빼는 자리가 B05 뿐이라 화면을 오가야 했음. 종단 그래프 렌더러·알약 레인·좌측 배치 폼은 이미 공용이었으므로, 빠져 있던 조작층만 B05 와 같은 부품(mountStructureMenu)으로 B06 에도 붙임. 관 추가·삭제는 이동과 같은 예약 경로를 씀 — 세션에 쌓고 [저장]·[확정]에서 정본 (pipe_points.json)으로 나가며, 세부 배수유역 재분할은 그때 서버가 처리함. 넣었다 바로 빼거나 뺐다 다시 넣으면 예약만 걷어 내 정본이 흔들리지 않음. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
@@ -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<PendingEdits>) : 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<void> {
|
||||
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;
|
||||
|
||||
@@ -203,6 +203,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
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<void> {
|
||||
);
|
||||
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
|
||||
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,
|
||||
|
||||
@@ -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<string, number | string>) => 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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<StructureInstance>,
|
||||
types: ReadonlyArray<StructureType>,
|
||||
) => 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<StructureInstance> = [];
|
||||
let markTypes: ReadonlyArray<StructureType> = [];
|
||||
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;
|
||||
|
||||
@@ -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<StructureInstance>;
|
||||
types: ReadonlyArray<StructureType>;
|
||||
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),
|
||||
});
|
||||
}
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user