feat(B05,B06): 자동저장을 걷어내고 초기값 스냅샷으로 초기화한다
CLAUDE.md 5장(조작·데이터 흐름 정책)을 코드에 반영한다. 조작분은 세션에만 쌓고 영구저장은 [저장]·[확정]에서만 하며, [초기화]는 재계산이 아니라 초기값 복원이다. 자동저장 폐지 - B06 조정창 기준벽 구간값: 800ms 디바운스 PUT을 없애고 세션(b06:culvertopt)에 담는다. flushCulvertOptions()를 B06 [저장]·[확정]과 B05 [저장]이 부른다. - B05 구조물: 조작 즉시 PUT + 서버 재조회로 화면을 덮어쓰던 것을 세션 (b05:structures) 적재로 바꾼다. 저장 전에도 고르고 지울 수 있도록 식별자를 crypto.randomUUID()로 미리 발급한다(서버는 빈 값일 때만 새로 발급). - B05에서 만지고 B06에서 확정하는 경로를 위해 flushPendingStructures()를 공용화. 초기값 스냅샷 - common_util_initial_snapshot: 자동설계 체인 성공 직후 정본 파일 4트리와 routes+자식 4표를 initial_snapshot/에 뜬다. 이후 읽기 전용. - reset_route_design: 스냅샷이 있으면 DELETE와 같은 트랜잭션에서 행을 되세우고 파일을 되돌린다. 없으면 종전 재계산 폴백. 응답에 restored를 더한다. 조작 응답 - 등고선 재적용·B06 진입 정합·[모두 적용]에서 전체 화면 오버레이 제거. - 재계산이 design을 갈아끼울 때 extra_spans를 보존한다(다른 조작값과 동일). 테스트: tmp/tests/test_initial_snapshot.py 5건 추가. 245 passed·8 failed(기존 실패 — 기슭막이 이관 때 placement가 interval→point로 바뀐 것을 테스트 미반영).
This commit is contained in:
@@ -6,8 +6,10 @@
|
||||
* 값을 B06 정본(design)에 따로 담지 않는 이유: 같은 숫자가 B05 배수관 카드에도
|
||||
* 있어서 두 곳이 갈리면 어느 쪽이 참인지 알 수 없다. 원천은 하나여야 한다.
|
||||
*
|
||||
* 저장은 **묶어서 늦게** 한다 — +/- 한 번마다 PUT하면 세부유역 재계산이 매번
|
||||
* 돌아간다. 화면은 캐시를 고쳐 즉시 반영하고, 저장은 마지막 조작 뒤 한 번만 간다.
|
||||
* 조작분은 **세션에만** 쌓고 영구저장은 [저장]·[확정]에서만 한다(CLAUDE.md 5장
|
||||
* 조작·데이터 흐름 정책, 2026-08-29 사용자 확정). 종전 800ms 디바운스 자동 PUT은
|
||||
* 폐지했다 — 저장을 누르지 않았는데 영구저장소가 바뀌면 [초기화]의 기준선이 흐려진다.
|
||||
* 세션에 담는 이유는 B06에서 만진 값을 B05 [저장]으로도 내보내야 하기 때문이다.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -16,83 +18,91 @@ import {
|
||||
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
|
||||
/** 마지막 조작 뒤 이만큼 조용하면 저장한다(ms). */
|
||||
const SAVE_DELAY_MS = 800;
|
||||
|
||||
/** 측점 하나에 얹을 옵션 조각 — 키는 `pipe_points` 옵션 키 그대로.
|
||||
* 값은 수치가 대부분이나 날개벽 설치("있음"/"없음")처럼 문자열도 있다. */
|
||||
export type CulvertOptionPatch = Record<string, number | string>;
|
||||
|
||||
/** 세션에 쌓인 조각 — 측점(누가거리 2자리 문자열) → 옵션 조각. */
|
||||
type PendingMap = Record<string, CulvertOptionPatch>;
|
||||
|
||||
export interface CulvertOptionWriter {
|
||||
/** 측점 옵션을 예약한다. 같은 측점의 앞선 예약과는 합쳐진다. */
|
||||
/** 측점 옵션을 세션에 예약한다. 같은 측점의 앞선 예약과는 합쳐진다. */
|
||||
queue: (chainageM: number, patch: CulvertOptionPatch) => 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 {
|
||||
/* 세션 저장 실패는 무시 — 값은 화면 캐시에 남아 있다. */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장기 하나를 만든다. `projectId`가 없으면 아무것도 하지 않는다(조회 전용 상태).
|
||||
* `onError`는 저장 실패를 화면에 알리는 쪽이 구현한다.
|
||||
* 세션에 쌓인 구간값을 `pipe_points.json`에 한 번에 쓴다. 예약분이 없으면 아무것도
|
||||
* 하지 않는다. 성공하면 세션을 비우고, 실패하면 **그대로 두어** 다음 [저장]에서
|
||||
* 다시 시도한다 — 조용히 잃으면 화면과 저장분이 갈린다.
|
||||
*/
|
||||
export async function flushCulvertOptions(
|
||||
projectId: string | null,
|
||||
sessionKey: string | null,
|
||||
): Promise<void> {
|
||||
const pending = readPending(sessionKey);
|
||||
const entries = Object.entries(pending);
|
||||
if (!projectId || entries.length === 0) return;
|
||||
const current = await fetchDetailPipePoints(projectId);
|
||||
let touched = false;
|
||||
const points: DetailPipeInput[] = current.pipe_points.map((point) => {
|
||||
// 좌표는 서버가 다시 계산하므로 싣지 않는다(DetailPipeInput 규약).
|
||||
const { lonlat: _lonlat, ...input } = point;
|
||||
const patch = pending[keyOf(point.chainage_m)];
|
||||
if (!patch) return input;
|
||||
touched = true;
|
||||
return { ...input, options: { ...(input.options ?? {}), ...patch } };
|
||||
});
|
||||
if (touched) await saveDetailPipePoints(projectId, points);
|
||||
writePending(sessionKey, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장기 하나를 만든다. `projectId`·`sessionKey`가 없으면 예약만 메모리에 남는다
|
||||
* (조회 전용 상태). `onError`는 [저장] 시점 실패를 화면에 알리는 쪽이 구현한다.
|
||||
*/
|
||||
export function createCulvertOptionWriter(
|
||||
projectId: () => string | null,
|
||||
sessionKey: () => string | null,
|
||||
onError?: (message: string) => void,
|
||||
): CulvertOptionWriter {
|
||||
/** 측점(누가거리 문자열) → 아직 안 보낸 옵션 조각. */
|
||||
const pending = new Map<string, CulvertOptionPatch>();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let inFlight: Promise<void> = Promise.resolve();
|
||||
|
||||
const keyOf = (chainageM: number): string => chainageM.toFixed(2);
|
||||
|
||||
async function send(): Promise<void> {
|
||||
const id = projectId();
|
||||
if (!id || pending.size === 0) {
|
||||
pending.clear();
|
||||
return;
|
||||
}
|
||||
const batch = new Map(pending);
|
||||
pending.clear();
|
||||
try {
|
||||
const current = await fetchDetailPipePoints(id);
|
||||
let touched = false;
|
||||
const points: DetailPipeInput[] = current.pipe_points.map((point) => {
|
||||
// 좌표는 서버가 다시 계산하므로 싣지 않는다(DetailPipeInput 규약).
|
||||
const { lonlat: _lonlat, ...input } = point;
|
||||
const patch = batch.get(keyOf(point.chainage_m));
|
||||
if (!patch) return input;
|
||||
touched = true;
|
||||
return { ...input, options: { ...(input.options ?? {}), ...patch } };
|
||||
});
|
||||
if (!touched) return;
|
||||
await saveDetailPipePoints(id, points);
|
||||
} catch (error) {
|
||||
// 실패한 조각은 되살려 다음 기회에 다시 시도한다 — 조용히 잃으면 화면과
|
||||
// 저장분이 갈린다.
|
||||
batch.forEach((patch, key) => {
|
||||
pending.set(key, { ...patch, ...(pending.get(key) ?? {}) });
|
||||
});
|
||||
onError?.(error instanceof Error ? error.message : "배수관 구간값 저장 실패");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
queue(chainageM, patch) {
|
||||
const key = keyOf(chainageM);
|
||||
pending.set(key, { ...(pending.get(key) ?? {}), ...patch });
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
inFlight = inFlight.then(send);
|
||||
}, SAVE_DELAY_MS);
|
||||
const key = sessionKey();
|
||||
const pending = readPending(key);
|
||||
pending[keyOf(chainageM)] = { ...(pending[keyOf(chainageM)] ?? {}), ...patch };
|
||||
writePending(key, pending);
|
||||
},
|
||||
async flush() {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
try {
|
||||
await flushCulvertOptions(projectId(), sessionKey());
|
||||
} catch (error) {
|
||||
onError?.(error instanceof Error ? error.message : "배수관 구간값 저장 실패");
|
||||
throw error;
|
||||
}
|
||||
inFlight = inFlight.then(send);
|
||||
await inFlight;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type SectionDetailResponse,
|
||||
type StandardCrossSection,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||
import { buildCrossPatches } from "./B06_Section_UI_Page_Patches";
|
||||
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
|
||||
@@ -170,6 +171,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
basin_adjust: target.design?.basin_adjust,
|
||||
revet_adjust: target.design?.revet_adjust,
|
||||
extra_wall_counts: target.design?.extra_wall_counts,
|
||||
extra_spans: target.design?.extra_spans,
|
||||
revet_link_detached: target.design?.revet_link_detached,
|
||||
revet_follow_grade: target.design?.revet_follow_grade,
|
||||
};
|
||||
@@ -230,7 +232,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
return staleByPlan.has(section.chainage_m);
|
||||
});
|
||||
if (!stale.length) return;
|
||||
showLoadingOverlay();
|
||||
// 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다
|
||||
// (CLAUDE.md 5장).
|
||||
try {
|
||||
const alignment = sectionDetail.longitudinal.profile_alignment as
|
||||
| {
|
||||
@@ -264,6 +267,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
basin_adjust: section.design?.basin_adjust,
|
||||
revet_adjust: section.design?.revet_adjust,
|
||||
extra_wall_counts: section.design?.extra_wall_counts,
|
||||
extra_spans: section.design?.extra_spans,
|
||||
revet_link_detached: section.design?.revet_link_detached,
|
||||
revet_follow_grade: section.design?.revet_follow_grade,
|
||||
};
|
||||
@@ -273,8 +277,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`${L("B06_Design_Failed")}${detail}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,11 +315,14 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
/** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다.
|
||||
* handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만
|
||||
* 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */
|
||||
let applyingAll = false;
|
||||
async function applyPanelToAll(): Promise<void> {
|
||||
if (!sectionDetail || !projectId || currentRouteId === null) return;
|
||||
if (applyingAll || !sectionDetail || !projectId || currentRouteId === null) return;
|
||||
const targets = sectionDetail.cross_sections.filter((section) => section.design);
|
||||
if (!targets.length) return;
|
||||
showLoadingOverlay();
|
||||
// 화면을 잠그지 않는다 — 카드가 하나씩 갱신되는 것이 곧 진행 표시다(CLAUDE.md 5장).
|
||||
// 대신 도는 동안 다시 누르는 것만 막는다.
|
||||
applyingAll = true;
|
||||
try {
|
||||
for (const section of targets) {
|
||||
const change = changeFromDesign(section.chainage_m);
|
||||
@@ -325,7 +330,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
showToast(L("B06_Std_ApplyAll_Success"), "success");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
applyingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,6 +552,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
if (!projectId || currentRouteId === null) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
// 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다
|
||||
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만).
|
||||
await stationControls.flushCulvertOptions();
|
||||
// B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다.
|
||||
await flushPendingStructures(projectId);
|
||||
const edits = collectSectionEdits();
|
||||
await saveSections(
|
||||
projectId,
|
||||
@@ -568,6 +578,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
if (!projectId || currentRouteId === null) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
// 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다
|
||||
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만).
|
||||
await stationControls.flushCulvertOptions();
|
||||
// B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다.
|
||||
await flushPendingStructures(projectId);
|
||||
const edits = collectSectionEdits();
|
||||
await confirmSections(
|
||||
projectId,
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface StationControlDeps {
|
||||
| "basinadjust"
|
||||
| "extrawall"
|
||||
| "extraspan"
|
||||
| "culvertopt"
|
||||
| "revetlink"
|
||||
| "fordadjust"
|
||||
| "boxadjust",
|
||||
@@ -501,7 +502,11 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
* 캐시(detail의 culvert 스펙)를 고쳐 즉시 보여주고, 저장은 페이지가 예약해 늦게
|
||||
* 한 번 내보낸다. 링크 측점에서 만져도 바뀌는 것은 **소유 측점** 값이다.
|
||||
* 연동은 측점별(세션 → 정본 → 기본 켬), 종단경사 반영은 소유 측점에 하나. */
|
||||
const culvertOptions = createCulvertOptionWriter(deps.projectId, deps.onSaveError);
|
||||
const culvertOptions = createCulvertOptionWriter(
|
||||
deps.projectId,
|
||||
() => deps.sessionKey("culvertopt"),
|
||||
deps.onSaveError,
|
||||
);
|
||||
const linkSession = createLinkFlagSession(() => deps.sessionKey("revetlink"));
|
||||
const linkDetached = linkSession.detached;
|
||||
const followGrades = linkSession.followGrade;
|
||||
|
||||
Reference in New Issue
Block a user