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
234 lines
10 KiB
TypeScript
234 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_Api_Culvert_Options.ts
|
|
* B06 조정창에서 만진 **배수관 세트 구간값**(기슭막이·집수정 길이·전/후)을
|
|
* 원천인 `pipe_points.json`에 되돌려 쓴다(2026-08-24 사용자).
|
|
*
|
|
* 값을 B06 정본(design)에 따로 담지 않는 이유: 같은 숫자가 B05 배수관 카드에도
|
|
* 있어서 두 곳이 갈리면 어느 쪽이 참인지 알 수 없다. 원천은 하나여야 한다.
|
|
*
|
|
* 조작분은 **세션에만** 쌓고 영구저장은 [저장]·[확정]에서만 한다(CLAUDE.md 5장
|
|
* 조작·데이터 흐름 정책, 2026-08-29 사용자 확정). 종전 800ms 디바운스 자동 PUT은
|
|
* 폐지했다 — 저장을 누르지 않았는데 영구저장소가 바뀌면 [초기화]의 기준선이 흐려진다.
|
|
* 세션에 담는 이유는 B06에서 만진 값을 B05 [저장]으로도 내보내야 하기 때문이다.
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
fetchDetailPipePoints,
|
|
saveDetailPipePoints,
|
|
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
|
|
/** 측점 하나에 얹을 옵션 조각 — 키는 `pipe_points` 옵션 키 그대로.
|
|
* 값은 수치가 대부분이나 날개벽 설치("있음"/"없음")처럼 문자열도 있다. */
|
|
export type CulvertOptionPatch = Record<string, number | string>;
|
|
|
|
/** 세션에 쌓인 조각 — 측점(누가거리 2자리 문자열) → 옵션 조각. */
|
|
type PendingMap = Record<string, CulvertOptionPatch>;
|
|
|
|
export interface CulvertOptionWriter {
|
|
/** 측점 옵션을 세션에 예약한다. 같은 측점의 앞선 예약과는 합쳐진다. */
|
|
queue: (chainageM: number, patch: CulvertOptionPatch) => void;
|
|
/** 기준 측점 이동을 예약한다(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>;
|
|
}
|
|
|
|
const keyOf = (chainageM: number): string => chainageM.toFixed(2);
|
|
|
|
function readPending(sessionKey: string | null): PendingMap {
|
|
if (!sessionKey) return {};
|
|
try {
|
|
const raw = window.sessionStorage.getItem(sessionKey);
|
|
return raw ? (JSON.parse(raw) as PendingMap) : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function writePending(sessionKey: string | null, pending: PendingMap): void {
|
|
if (!sessionKey) return;
|
|
try {
|
|
if (Object.keys(pending).length === 0) window.sessionStorage.removeItem(sessionKey);
|
|
else window.sessionStorage.setItem(sessionKey, JSON.stringify(pending));
|
|
} catch {
|
|
/* 세션 저장 실패는 무시 — 값은 화면 캐시에 남아 있다. */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 세션에 쌓인 구간값을 `pipe_points.json`에 한 번에 쓴다. 예약분이 없으면 아무것도
|
|
* 하지 않는다. 성공하면 세션을 비우고, 실패하면 **그대로 두어** 다음 [저장]에서
|
|
* 다시 시도한다 — 조용히 잃으면 화면과 저장분이 갈린다.
|
|
*/
|
|
/** 예약된 이동 — 옛 측점 키 → 새 누가거리(m). */
|
|
type PendingMoves = Record<string, number>;
|
|
|
|
function readMoves(sessionKey: string | null): PendingMoves {
|
|
if (!sessionKey) return {};
|
|
try {
|
|
const raw = window.sessionStorage.getItem(sessionKey);
|
|
return raw ? (JSON.parse(raw) as PendingMoves) : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/** 예약된 추가·삭제 — 이동과 같은 세션 칸에 함께 담는다(키 하나로 읽고 쓴다). */
|
|
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);
|
|
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 = edits.adds.length > 0 || edits.removes.length > 0;
|
|
const points: DetailPipeInput[] = current.pipe_points.map((point) => {
|
|
// 좌표는 서버가 다시 계산하므로 싣지 않는다(DetailPipeInput 규약).
|
|
const { lonlat: _lonlat, ...input } = point;
|
|
const key = keyOf(point.chainage_m);
|
|
const patch = pending[key];
|
|
const movedTo = moves[key];
|
|
if (!patch && movedTo === undefined) return input;
|
|
touched = true;
|
|
const next: DetailPipeInput = patch
|
|
? { ...input, options: { ...(input.options ?? {}), ...patch } }
|
|
: { ...input };
|
|
// 기준 측점 이동 — 관 위치가 바뀌면 서버가 세부 배수유역을 다시 나눠 응답한다.
|
|
if (movedTo !== undefined) next.chainage_m = movedTo;
|
|
return next;
|
|
});
|
|
// 지울 것을 걷어 내고 넣을 것을 붙인다 — 서버는 이 목록을 그대로 정본으로 삼는다.
|
|
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: [] });
|
|
}
|
|
|
|
/**
|
|
* 저장기 하나를 만든다. `projectId`·`sessionKey`가 없으면 예약만 메모리에 남는다
|
|
* (조회 전용 상태). `onError`는 [저장] 시점 실패를 화면에 알리는 쪽이 구현한다.
|
|
*/
|
|
export function createCulvertOptionWriter(
|
|
projectId: () => string | null,
|
|
sessionKey: () => string | null,
|
|
onError?: (message: string) => void,
|
|
/** 이동 예약 세션 키 — 없으면 이동은 메모리에도 남지 않는다. */
|
|
moveKey: () => string | null = () => null,
|
|
/** 추가·삭제 예약 세션 키 — 없으면 추가·삭제를 받지 않는다. */
|
|
editKey: () => string | null = () => null,
|
|
): CulvertOptionWriter {
|
|
return {
|
|
queue(chainageM, patch) {
|
|
const key = sessionKey();
|
|
const pending = readPending(key);
|
|
pending[keyOf(chainageM)] = { ...(pending[keyOf(chainageM)] ?? {}), ...patch };
|
|
writePending(key, pending);
|
|
},
|
|
queueMove(fromChainageM, toChainageM) {
|
|
const key = moveKey();
|
|
if (!key) return;
|
|
const moves = readMoves(key);
|
|
// 이미 옮긴 관을 또 옮기면 **원래 자리** 기준으로 최종 위치만 남긴다.
|
|
const origin =
|
|
Object.keys(moves).find((entry) => Math.abs(moves[entry] - fromChainageM) < 0.005) ??
|
|
keyOf(fromChainageM);
|
|
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(), editKey());
|
|
} catch (error) {
|
|
onError?.(error instanceof Error ? error.message : "배수관 구간값 저장 실패");
|
|
throw error;
|
|
}
|
|
},
|
|
};
|
|
}
|