refactor(B05,B06): 설계 초안·계산 결과도 등록표로 — 이동할 때 다시 부르지 않게

캐시·세션 일원화 2단계 (2026-09-06 사용자 지시).

- ② 설계 초안 이관 — 구조물 미저장분, 상단측 변경분, 페이지 간 넘김값(구조물 선택),
  표준횡단 편집값, 조정창 4축·단수·연동값 8종, 암 경계선, 표시 반폭. 넘김값은 별도
  임시 키를 없애고 초안 한 자리로 흡수.
- ④ 계산 결과 이관 — 최신 노선 응답, 서버가 준 규격 횡단·암 경계 기본값. 횡단 상세는
  메모리 캐시에 더해 세션에도 한 벌 얹어 새로고침 뒤 첫 화면이 서버를 안 기다림
  (용량을 넘으면 조용히 건너뛰고 예전대로 다시 받음).
- B05 진입의 강제 새로읽기를 걷어냄 — 캐시로 먼저 그리고 노선번호·지표면 서명으로
  뒤에서 신선도만 확인, 달라졌을 때만 다시 그림.
- [초기화]의 초안 비우기를 `clearDrafts` 한 곳으로 모음(예전에는 파일마다 따로).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 11:48:40 +09:00
co-authored by Claude Opus 5
parent 05a63c8440
commit a126df25bb
10 changed files with 131 additions and 161 deletions
+23 -13
View File
@@ -108,30 +108,40 @@ export const STATE_REGISTRY = {
rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` }, rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` },
/** 규격 횡단 측점별 지정. */ /** 규격 횡단 측점별 지정. */
"std-cross": { bucket: "draft", scope: "project", legacy: (p) => `b06:std-cross:${p}` }, "std-cross": { bucket: "draft", scope: "project", legacy: (p) => `b06:std-cross:${p}` },
/** 규격 횡단 기본값(프로젝트 단위). */ /** 표시 반폭 — 사용자가 고른 값이라 초안이되, 바꾸면 횡단 재생성(③)을 함께 부른다. */
"std-cross-default": {
bucket: "draft",
scope: "project",
legacy: (p) => `b06:std-cross-default:${p}`,
},
/** 암 경계선 기본값(프로젝트 단위). */
"rock-boundary-default": {
bucket: "draft",
scope: "project",
legacy: (p) => `b06:rock-boundary-default:${p}`,
},
/** 표시 반폭 — 화면 값이지만 횡단 재생성을 부르므로 노선에 묶는다. */
"cross-display": { "cross-display": {
bucket: "draft", bucket: "draft",
scope: "route", scope: "route",
legacy: (p, r) => `b06:cross-display:${p}:${r}`, legacy: (p, r) => `b06:cross-display:${p}:${r}`,
}, },
/* 측점 조정창이 쌓는 4축·단수·연동 값 — 전부 노선 단위 초안이고 [저장]에서 함께 나간다.
옛 키는 `b06:{이름}:{프로젝트}:{노선}` 한 규칙이었다. */
crossw: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:crossw:${p}:${r}` },
revetx: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:revetx:${p}:${r}` },
revetlink: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:revetlink:${p}:${r}` },
basinadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:basinadjust:${p}:${r}` },
inletstruct: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:inletstruct:${p}:${r}` },
extrawall: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:extrawall:${p}:${r}` },
fordadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:fordadjust:${p}:${r}` },
boxadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:boxadjust:${p}:${r}` },
/* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */ /* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */
/** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */ /** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */
latest: { bucket: "result", scope: "project", legacy: (p) => `b05:latest:${p}` }, latest: { bucket: "result", scope: "project", legacy: (p) => `b05:latest:${p}` },
/** 횡단 상세 응답 — 예전에는 메모리에만 있어 페이지를 떠나면 사라졌다. */ /** 횡단 상세 응답 — 예전에는 메모리에만 있어 페이지를 떠나면 사라졌다. */
"section-detail": { bucket: "result", scope: "route" }, "section-detail": { bucket: "result", scope: "route" },
/** 서버가 준 규격 횡단 기본값 — 사용자가 만든 값이 아니라 **기억해 둔 서버 값**이다. */
"std-cross-default": {
bucket: "result",
scope: "project",
legacy: (p) => `b06:std-cross-default:${p}`,
},
/** 서버가 준 암 경계선 기본 오프셋 — 위와 같은 성격. */
"rock-boundary-default": {
bucket: "result",
scope: "project",
legacy: (p) => `b06:rock-boundary-default:${p}`,
},
} as const satisfies Record<string, StateEntry>; } as const satisfies Record<string, StateEntry>;
export type StateName = keyof typeof STATE_REGISTRY; export type StateName = keyof typeof STATE_REGISTRY;
+9 -26
View File
@@ -11,6 +11,8 @@
* - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환. * - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환.
* ========================================================================== */ * ========================================================================== */
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
/** 경로 제어점 (BP/EP/CP) */ /** 경로 제어점 (BP/EP/CP) */
@@ -292,39 +294,20 @@ export async function fetchLatestRoute(projectId: string): Promise<RouteLatestRe
}); });
} }
/** B05가 최신 경로·확정 설정값을 탭 세션에 담아 둘 때 쓰는 키(유일한 정의처). */ /* 최신 경로 응답은 ④ 계산 결과다 — 키·이관은 등록표(`b_page_state`)가 맡는다.
export const routeLatestCacheKey = (projectId: string): string => `b05:latest:${projectId}`; B04에서 지표면을 다시 확정하면 옛 확정값이 남아 B05가 이전 지형을 그리므로, 확정
직후 `clearRouteLatestCache`로 버린다. */
/** 담아 둔 최신 경로 값을 버린다. B04에서 지표면을 다시 확정하면 옛 확정값이 남아
* B05가 이전 지형을 그리게 되므로, 확정 직후 이 값을 지운다. */
/** 세션 캐시에서 최신 경로 응답을 읽는다. 없거나 깨졌으면 null(다음 진입은 DB 조회). */ /** 세션 캐시에서 최신 경로 응답을 읽는다. 없거나 깨졌으면 null(다음 진입은 DB 조회). */
export function readRouteLatestCache(projectId: string): RouteLatestResponse | null { export function readRouteLatestCache(projectId: string): RouteLatestResponse | null {
try { return readState<RouteLatestResponse>("latest", projectId);
const raw = window.sessionStorage.getItem(routeLatestCacheKey(projectId));
return raw ? (JSON.parse(raw) as RouteLatestResponse) : null;
} catch {
return null;
}
} }
/** 최신 경로 응답을 세션 캐시에 넣는다. 용량 초과 등으로 실패하면 캐시를 비운다. */ /** 최신 경로 응답을 세션 캐시에 넣는다. 용량 초과 등으로 실패해도 화면은 그대로 돈다. */
export function writeRouteLatestCache(projectId: string, value: RouteLatestResponse): void { export function writeRouteLatestCache(projectId: string, value: RouteLatestResponse): void {
const key = routeLatestCacheKey(projectId); writeState("latest", value, projectId);
try {
window.sessionStorage.setItem(key, JSON.stringify(value));
} catch {
try {
window.sessionStorage.removeItem(key);
} catch {
/* noop */
}
}
} }
export function clearRouteLatestCache(projectId: string): void { export function clearRouteLatestCache(projectId: string): void {
try { clearState("latest", projectId);
window.sessionStorage.removeItem(routeLatestCacheKey(projectId));
} catch {
/* 세션 접근 실패 시에는 다음 진입에서 DB를 읽게 되므로 그대로 둔다. */
}
} }
+4 -16
View File
@@ -11,6 +11,7 @@
* 하려는 것이라, 목록은 반드시 서버에서 받아 온다. * 하려는 것이라, 목록은 반드시 서버에서 받아 온다.
* ========================================================================== */ * ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
/** 배치형태 — 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). */ /** 배치형태 — 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). */
@@ -193,26 +194,13 @@ export function defaultOptions(type: StructureType): Record<string, string | num
* 2026-08-29 사용자 확정). B05에서 만지고 B06으로 넘어가 확정하는 경로가 있어 * 2026-08-29 사용자 확정). B05에서 만지고 B06으로 넘어가 확정하는 경로가 있어
* 읽기·쓰기·내보내기를 여기 한 곳에 둔다. */ * 읽기·쓰기·내보내기를 여기 한 곳에 둔다. */
const pendingStructuresKey = (projectId: string): string => `b05:structures:${projectId}`; /** 미저장 조작분(② 설계 초안). 없으면 null(= 만진 적 없음, 빈 목록과 구분된다). */
/** 미저장 조작분. 없으면 null(= 만진 적 없음, 빈 목록과 구분된다). */
export function readPendingStructures(projectId: string): StructureInstance[] | null { export function readPendingStructures(projectId: string): StructureInstance[] | null {
try { return readState<StructureInstance[]>("structures", projectId);
const raw = window.sessionStorage.getItem(pendingStructuresKey(projectId));
return raw ? (JSON.parse(raw) as StructureInstance[]) : null;
} catch {
return null;
}
} }
export function writePendingStructures(projectId: string, next: StructureInstance[] | null): void { export function writePendingStructures(projectId: string, next: StructureInstance[] | null): void {
try { writeState("structures", next, projectId);
const key = pendingStructuresKey(projectId);
if (next) window.sessionStorage.setItem(key, JSON.stringify(next));
else window.sessionStorage.removeItem(key);
} catch {
/* 세션 저장 실패는 무시 — 값은 화면에 남아 있다. */
}
} }
/** /**
+36 -2
View File
@@ -233,6 +233,35 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
return fresh; return fresh;
} }
/** 이 응답이 "같은 자료"인지 가리는 서명 — 노선 번호·지표면 모델·지표면 설정. */
const latestSignature = (value: RouteLatestResponse): string =>
[
value.route?.id ?? "",
value.route?.surface_model_id ?? "",
value.surface_params.source_filter,
value.surface_params.method,
String(value.surface_params.smooth),
].join("|");
/**
* 캐시로 먼저 그린 뒤 **뒤에서** 신선도를 확인한다(2026-09-06 캐시·세션 일원화).
*
* 예전에는 진입 때마다 `loadLatest(true)`로 DB를 다시 읽었다 — 다른 탭의 재업로드로
* 옛 노선번호가 남을까 봐 넣은 안전장치인데, 그 탓에 B05↔B06을 오갈 때마다 화면이
* 처음부터 다시 섰다. 이제 서명(노선번호·지표면)이 달라졌을 때만 다시 그린다.
*/
async function verifyLatestFreshness(shown: RouteLatestResponse): Promise<void> {
try {
const fresh = await fetchLatestRoute(activeProjectId);
writeLatestCache(fresh);
if (latestSignature(fresh) === latestSignature(shown)) return;
renderLatest(fresh);
if (fresh.route?.id) await restoreSections(fresh.route.id);
} catch {
/* 확인 실패는 조용히 넘긴다 — 화면은 캐시로 이미 서 있다. */
}
}
const panel = createRoutePanel({ const panel = createRoutePanel({
onSolve: () => void solveRouteAction(actionContext), onSolve: () => void solveRouteAction(actionContext),
onTempSave: () => void tempSaveAction(actionContext), onTempSave: () => void tempSaveAction(actionContext),
@@ -618,9 +647,11 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
try { try {
// ② 좌측 폼·노선 설정값 — 도착하는 대로 폼과 3D 마커 복원에 쓴다. // ② 좌측 폼·노선 설정값 — 도착하는 대로 폼과 3D 마커 복원에 쓴다.
// 캐시가 있으면 그것으로 **먼저** 그리고 신선도는 뒤에서 확인한다 — B05↔B06 이동에서
// 서버를 다시 부르지 않으려는 것이다(2026-09-06). 캐시가 없을 때만 DB를 기다린다.
const cachedLatest = readLatestCache();
const [latestResponse, sectionContext, configuredRoadWidths] = await Promise.all([ const [latestResponse, sectionContext, configuredRoadWidths] = await Promise.all([
// 다른 탭의 재업로드로 옛 route_id가 남을 수 있어 진입 때는 DB 최신값을 읽는다. cachedLatest ? Promise.resolve(cachedLatest) : loadLatest(true),
loadLatest(true),
fetchSectionContext(activeProjectId), fetchSectionContext(activeProjectId),
fetchRoadWidths(activeProjectId), fetchRoadWidths(activeProjectId),
]); ]);
@@ -666,6 +697,9 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후). // 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후).
await bridge.load(); await bridge.load();
advanceLoading(""); advanceLoading("");
// 캐시로 그렸다면 이제 뒤에서 신선도만 확인한다 — 화면은 이미 서 있으므로 기다리지
// 않는다. 다른 탭이 자료를 갈아 끼웠을 때만 다시 그린다.
if (cachedLatest) void verifyLatestFreshness(latestResponse);
} catch (error) { } catch (error) {
showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error"); showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error");
} finally { } finally {
+5 -5
View File
@@ -29,7 +29,7 @@ import {
invalidateSectionDetail, invalidateSectionDetail,
saveCachedCrossPatches, saveCachedCrossPatches,
} from "../B06_Section/B06_Section_Section_Store"; } from "../B06_Section/B06_Section_Section_Store";
import { clearStandardCrossSession } from "../B06_Section/B06_Section_UI_Standard_Panel"; import { clearDrafts } from "../A00_Common/b_page_state";
import { saveCorridorIfDirty } from "./B05_Profile_UI_Corridor"; import { saveCorridorIfDirty } from "./B05_Profile_UI_Corridor";
import { circlePoint, routePoint } from "./B05_Profile_UI_Page_Helpers"; import { circlePoint, routePoint } from "./B05_Profile_UI_Page_Helpers";
import { clearAlignmentDrafts } from "./B05_Profile_UI_Profile_Edit"; import { clearAlignmentDrafts } from "./B05_Profile_UI_Profile_Edit";
@@ -202,11 +202,11 @@ export async function resetDesignAction(ctx: PageActionContext): Promise<void> {
// 옛 경로 기준 캐시를 전부 비우고 페이지를 새로 그린다 — 초기 계산본이 정본이 된다. // 옛 경로 기준 캐시를 전부 비우고 페이지를 새로 그린다 — 초기 계산본이 정본이 된다.
clearRouteLatestCache(ctx.projectId); clearRouteLatestCache(ctx.projectId);
invalidateSectionDetail(ctx.projectId); invalidateSectionDetail(ctx.projectId);
// 프로젝트 단위 세션 값도 함께 버린다 — 키에 route_id가 없어 새 노선에 그대로 // 사용자 조작(② 초안)은 **등록표 한 곳**에서 통째로 버린다(2026-09-06 일원화).
// 되붙는다(2026-08-28). 초기화는 "사용자 편집을 전부 버린다"가 규약이다. // 예전에는 파일마다 따로 지워 새 값이 늘 때 빠뜨리기 쉬웠다. 노선 범위 초안은
// 노선이 바뀌면 키가 달라져 자연히 딸려 오지 않는다.
clearDrafts(ctx.projectId, ctx.latest()?.route?.id ?? null);
ctx.uphillOverrides.clear(); ctx.uphillOverrides.clear();
ctx.persistUphillOverrides();
clearStandardCrossSession(ctx.projectId);
// 계획선 편집 초안도 함께 버린다 — 남기면 초기값 위에 옛 편집이 다시 얹혀 계획선이 // 계획선 편집 초안도 함께 버린다 — 남기면 초기값 위에 옛 편집이 다시 얹혀 계획선이
// 측점에서 원지반선과 만나지 않는다(2026-09-04 실측: 새로고침 후 최대 6.0m 어긋남). // 측점에서 원지반선과 만나지 않는다(2026-09-04 실측: 새로고침 후 최대 6.0m 어긋남).
clearAlignmentDrafts(); clearAlignmentDrafts();
+7 -13
View File
@@ -7,6 +7,7 @@
* 전부 상태를 갖지 않는 변환 함수라 화면 흐름과 독립적이다. * 전부 상태를 갖지 않는 변환 함수라 화면 흐름과 독립적이다.
* ========================================================================== */ * ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import type { RoutePanelValues } from "./B05_Profile_UI_Panel"; import type { RoutePanelValues } from "./B05_Profile_UI_Panel";
import type { import type {
ModelBounds, ModelBounds,
@@ -246,19 +247,15 @@ export const FACILITY_NAMES: Record<PipeFacility, string> = {
/* ── 측점 상단측(=측구 방향) 사용자 변경분 세션 보관 ───────────────────── /* ── 측점 상단측(=측구 방향) 사용자 변경분 세션 보관 ─────────────────────
* 3D 램프 클릭으로 바꾼 값. 경로 확정 때 uphill_overrides로 백엔드에 병합한다. * 3D 램프 클릭으로 바꾼 값. 경로 확정 때 uphill_overrides로 백엔드에 병합한다.
* 화면 본체가 700줄에 닿아 읽기·쓰기만 여기로 뺐다(2026-09-04, 동작 불변). */ * 화면 본체가 700줄에 닿아 읽기·쓰기만 여기로 뺐다(2026-09-04, 동작 불변). */
const uphillSessionKey = (projectId: string): string => `b05:uphill:${projectId}`;
/** 세션에 남은 상단측 변경분을 읽는다. 손상된 값은 무시하고 빈 것으로 시작한다. */ /** 세션에 남은 상단측 변경분을 읽는다. 손상된 값은 무시하고 빈 것으로 시작한다. */
export function loadUphillOverrides(projectId: string): Map<string, "left" | "right"> { export function loadUphillOverrides(projectId: string): Map<string, "left" | "right"> {
const overrides = new Map<string, "left" | "right">(); const overrides = new Map<string, "left" | "right">();
try { try {
const raw = window.sessionStorage.getItem(uphillSessionKey(projectId)); const stored = readState<Record<string, "left" | "right">>("uphill", projectId);
if (!raw) return overrides; if (!stored) return overrides;
Object.entries(JSON.parse(raw) as Record<string, "left" | "right">).forEach( Object.entries(stored).forEach(([chainage, side]) => {
([chainage, side]) => { if (side === "left" || side === "right") overrides.set(chainage, side);
if (side === "left" || side === "right") overrides.set(chainage, side); });
},
);
} catch { } catch {
/* 손상된 세션 값은 무시 — 자동 판정값으로 재시작. */ /* 손상된 세션 값은 무시 — 자동 판정값으로 재시작. */
} }
@@ -271,10 +268,7 @@ export function saveUphillOverrides(
overrides: ReadonlyMap<string, "left" | "right">, overrides: ReadonlyMap<string, "left" | "right">,
): void { ): void {
try { try {
window.sessionStorage.setItem( writeState("uphill", Object.fromEntries(overrides), projectId);
uphillSessionKey(projectId),
JSON.stringify(Object.fromEntries(overrides)),
);
} catch { } catch {
/* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */ /* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */
} }
@@ -9,6 +9,7 @@
* 적혀, 어느 쪽으로 오가든 마지막 선택이 그대로 살아 있다. * 적혀, 어느 쪽으로 오가든 마지막 선택이 그대로 살아 있다.
* ========================================================================== */ * ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import type { StructurePick, StructurePickControls } from "./B05_Profile_UI_Viewer_Structure_Pick"; import type { StructurePick, StructurePickControls } from "./B05_Profile_UI_Viewer_Structure_Pick";
import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types"; import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types";
import type { StructureInstance } from "./B05_Profile_Api_Structures"; import type { StructureInstance } from "./B05_Profile_Api_Structures";
@@ -19,21 +20,15 @@ export interface StructurePickHandoff {
key?: string; key?: string;
} }
const sessionKey = (projectId: string): string => `aislo:structure-pick:${projectId}`; /* 넘김값도 ② 설계 초안이다 — 별도 임시 키를 두지 않고 등록표(`b_page_state`)를 쓴다
(2026-09-06 캐시·세션 일원화). */
/** 세션에 남긴다(선택 해제면 지운다). */ /** 세션에 남긴다(선택 해제면 지운다). */
export function rememberStructurePick(projectId: string | null, pick: StructurePick | null): void { export function rememberStructurePick(projectId: string | null, pick: StructurePick | null): void {
if (!projectId) return; if (!projectId) return;
try { if (!pick) return writeState("structure-pick", null, projectId);
if (!pick) { const handoff: StructurePickHandoff = { at: pick.chainageM, key: pick.key };
window.sessionStorage.removeItem(sessionKey(projectId)); writeState("structure-pick", handoff, projectId);
return;
}
const handoff: StructurePickHandoff = { at: pick.chainageM, key: pick.key };
window.sessionStorage.setItem(sessionKey(projectId), JSON.stringify(handoff));
} catch {
/* 세션 저장소가 막힌 환경 — 화면 선택만 살고 넘김은 포기한다. */
}
} }
/** 화면에서 고른 것을 세션에 적는다 — B06 쪽 창구(측점만 고르면 부재키는 비운다). */ /** 화면에서 고른 것을 세션에 적는다 — B06 쪽 창구(측점만 고르면 부재키는 비운다). */
@@ -43,21 +38,15 @@ export function writeStructurePick(
key?: string, key?: string,
): void { ): void {
if (!projectId) return; if (!projectId) return;
try { writeState("structure-pick", at === null ? null : { at, key }, projectId);
if (at === null) window.sessionStorage.removeItem(sessionKey(projectId));
else window.sessionStorage.setItem(sessionKey(projectId), JSON.stringify({ at, key }));
} catch {
/* 세션 저장소가 막힌 환경 — 화면 선택만 살고 넘김은 포기한다. */
}
} }
/** 세션에 남은 선택을 읽는다(지우지 않는다). 없으면 null. */ /** 세션에 남은 선택을 읽는다(지우지 않는다). 없으면 null. */
export function readStructurePick(projectId: string | null): StructurePickHandoff | null { export function readStructurePick(projectId: string | null): StructurePickHandoff | null {
if (!projectId) return null; if (!projectId) return null;
try { try {
const raw = window.sessionStorage.getItem(sessionKey(projectId)); const value = readState<Partial<StructurePickHandoff>>("structure-pick", projectId);
if (!raw) return null; if (!value) return null;
const value = JSON.parse(raw) as Partial<StructurePickHandoff>;
if (typeof value.at !== "number" || !Number.isFinite(value.at)) return null; if (typeof value.at !== "number" || !Number.isFinite(value.at)) return null;
return { at: value.at, key: typeof value.key === "string" ? value.key : undefined }; return { at: value.at, key: typeof value.key === "string" ? value.key : undefined };
} catch { } catch {
+17 -3
View File
@@ -10,11 +10,14 @@
* 다음 그리기에서 그대로 본다 — 별도 동기화 코드가 필요 없다. * 다음 그리기에서 그대로 본다 — 별도 동기화 코드가 필요 없다.
* *
* ── 수명 규칙 ───────────────────────────────────────────────────── * ── 수명 규칙 ─────────────────────────────────────────────────────
* 키는 `projectId:routeId`. SPA 모듈 싱글턴이라 페이지를 오가도 살아 있고, 새로고침이면 * 키는 `projectId:routeId`. SPA 모듈 싱글턴이라 페이지를 오가도 살아 있다. 새로고침이면
* 사라져 영구저장소에서 다시 받는다(영구저장소가 항상 정본). 서버가 파일을 통째로 다시 * 메모리가 비므로 **세션에도 한 벌 얹어 둔다**(④ 계산 결과, 2026-09-06 캐시·세션 일원화)
* — 새로고침 뒤 첫 화면이 서버를 기다리지 않는다. 세션 용량을 넘으면 조용히 건너뛰고
* 예전처럼 영구저장소에서 다시 받는다(영구저장소가 항상 정본). 서버가 파일을 통째로 다시
* 쓰는 조작(재생성·계획선 편집 저장·확정)은 그 응답/재조회로 `replace`·`invalidate`한다. * 쓰는 조작(재생성·계획선 편집 저장·확정)은 그 응답/재조회로 `replace`·`invalidate`한다.
* ========================================================================== */ * ========================================================================== */
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
@@ -40,10 +43,17 @@ export async function loadSectionDetail(
if (cached) return cached; if (cached) return cached;
const inFlight = pending.get(key); const inFlight = pending.get(key);
if (inFlight) return inFlight; if (inFlight) return inFlight;
// 새로고침으로 메모리가 빈 경우 — 세션에 얹어 둔 한 벌로 바로 선다.
const stored = readState<SectionDetailResponse>("section-detail", projectId, routeId);
if (stored) {
cache.set(key, stored);
return stored;
}
} }
const request = fetchSectionDetail(projectId, routeId) const request = fetchSectionDetail(projectId, routeId)
.then((detail) => { .then((detail) => {
cache.set(key, detail); cache.set(key, detail);
writeState("section-detail", detail, projectId, routeId);
return detail; return detail;
}) })
.finally(() => { .finally(() => {
@@ -60,6 +70,7 @@ export function replaceSectionDetail(
detail: SectionDetailResponse, detail: SectionDetailResponse,
): void { ): void {
cache.set(keyOf(projectId, routeId), detail); cache.set(keyOf(projectId, routeId), detail);
writeState("section-detail", detail, projectId, routeId);
} }
/** /**
@@ -69,11 +80,14 @@ export function replaceSectionDetail(
export function invalidateSectionDetail(projectId: string, routeId?: number): void { export function invalidateSectionDetail(projectId: string, routeId?: number): void {
if (routeId !== undefined) { if (routeId !== undefined) {
cache.delete(keyOf(projectId, routeId)); cache.delete(keyOf(projectId, routeId));
clearState("section-detail", projectId, routeId);
return; return;
} }
const prefix = `${projectId}:`; const prefix = `${projectId}:`;
for (const key of [...cache.keys()]) { for (const key of [...cache.keys()]) {
if (key.startsWith(prefix)) cache.delete(key); if (!key.startsWith(prefix)) continue;
cache.delete(key);
clearState("section-detail", projectId, Number(key.slice(prefix.length)));
} }
} }
+5 -10
View File
@@ -1,5 +1,6 @@
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
import { stateKey, type StateName } from "../A00_Common/b_page_state";
import { navigateTo } from "../A00_Common/router"; import { navigateTo } from "../A00_Common/router";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
@@ -24,7 +25,6 @@ import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
import { import {
confirmCurrentSections, confirmCurrentSections,
createRockBoundaryStore, createRockBoundaryStore,
rockBoundarySessionKey,
saveCurrentSections, saveCurrentSections,
type SectionPersistContext, type SectionPersistContext,
} from "./B06_Section_UI_Page_Persist"; } from "./B06_Section_UI_Page_Persist";
@@ -391,10 +391,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
// 암 경계선 오프셋(측점별) 세션 저장소는 저장 흐름 모듈이 맡는다(2026-09-02 분리). // 암 경계선 오프셋(측점별) 세션 저장소는 저장 흐름 모듈이 맡는다(2026-09-02 분리).
const rockStore = createRockBoundaryStore({ const rockStore = createRockBoundaryStore({
sessionKey: () => sessionKey: () => stateKey("rockb", projectId, currentRouteId),
projectId && currentRouteId !== null
? rockBoundarySessionKey(projectId, currentRouteId)
: null,
detail: () => sectionDetail, detail: () => sectionDetail,
refreshCard: (chainageM) => sectionView.refreshCard(chainageM), refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
recompute: (chainageM) => recomputeIfRock(chainageM), recompute: (chainageM) => recomputeIfRock(chainageM),
@@ -403,8 +400,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
const rockBoundaryControl = rockStore.control; const rockBoundaryControl = rockStore.control;
const stationControls = createStationControls({ const stationControls = createStationControls({
sessionKey: (kind) => // 키는 등록표(`b_page_state`)가 만든다 — 이름만 넘기면 통·범위·옛 키 이관이 따라온다.
projectId && currentRouteId !== null ? `b06:${kind}:${projectId}:${currentRouteId}` : null, sessionKey: (kind) => stateKey(kind as StateName, projectId, currentRouteId),
refreshCard: (chainageM) => sectionView.refreshCard(chainageM), refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
detail: () => sectionDetail, detail: () => sectionDetail,
crossHalfWidth, crossHalfWidth,
@@ -505,9 +502,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
/** 표시 반폭 세션 키 — 페이지를 떠났다 와도 조절값이 유지되게 한다. */ /** 표시 반폭 세션 키 — 페이지를 떠났다 와도 조절값이 유지되게 한다. */
const displaySessionKey = (): string | null => const displaySessionKey = (): string | null =>
projectId && currentRouteId !== null stateKey("cross-display", projectId, currentRouteId);
? `b06:cross-display:${projectId}:${currentRouteId}`
: null;
function persistDisplayHalfWidth(): void { function persistDisplayHalfWidth(): void {
const key = displaySessionKey(); const key = displaySessionKey();
+16 -53
View File
@@ -17,6 +17,13 @@
* ( ). * ( ).
* ========================================================================== */ * ========================================================================== */
import {
clearState,
readState,
readStateRaw,
writeState,
writeStateRaw,
} from "../A00_Common/b_page_state";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createButton, createInputField, createSelectField } from "@ui/ui_template_elements"; import { createButton, createInputField, createSelectField } from "@ui/ui_template_elements";
import { buildStandardDiagram } from "./B06_Section_UI_Standard_Diagram"; import { buildStandardDiagram } from "./B06_Section_UI_Standard_Diagram";
@@ -32,18 +39,6 @@ function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex]; return ui_locales[key][currentLanguageIndex];
} }
const SESSION_PREFIX = "b06:std-cross:";
/** config 기본값 사본 — 편집값(`SESSION_PREFIX`)과 수명은 같되 용도가 다르다. */
const DEFAULTS_PREFIX = "b06:std-cross-default:";
function sessionKey(projectId: string): string {
return `${SESSION_PREFIX}${projectId}`;
}
function defaultsKey(projectId: string): string {
return `${DEFAULTS_PREFIX}${projectId}`;
}
/** /**
* **config ** (`sections/context` ). * **config ** (`sections/context` ).
* *
@@ -52,47 +47,28 @@ function defaultsKey(projectId: string): string {
* B05도 (2026-09-03 ). * B05도 (2026-09-03 ).
*/ */
export function rememberStandardDefaults(projectId: string, defaults: StandardCrossSection): void { export function rememberStandardDefaults(projectId: string, defaults: StandardCrossSection): void {
try { writeState("std-cross-default", defaults, projectId);
window.sessionStorage.setItem(defaultsKey(projectId), JSON.stringify(defaults));
} catch {
/* 세션 저장 실패는 무시 — 계산은 편집값·저장분으로 이어 간다. */
}
} }
/** 기억해 둔 config 기본값. 아직 컨텍스트를 못 받았으면 null. */ /** 기억해 둔 config 기본값. 아직 컨텍스트를 못 받았으면 null. */
export function readStandardDefaults(projectId: string): StandardCrossSection | null { export function readStandardDefaults(projectId: string): StandardCrossSection | null {
try { return readState<StandardCrossSection>("std-cross-default", projectId);
const raw = window.sessionStorage.getItem(defaultsKey(projectId));
return raw ? (JSON.parse(raw) as StandardCrossSection) : null;
} catch {
return null;
}
} }
const ROCK_DEFAULT_PREFIX = "b06:rock-boundary-default:";
/** /**
* (config `STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M`) . * (config `STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M`) .
* ** ** * ** **
* . * .
*/ */
export function rememberRockBoundaryDefault(projectId: string, offsetM: number): void { export function rememberRockBoundaryDefault(projectId: string, offsetM: number): void {
try { writeStateRaw("rock-boundary-default", String(offsetM), projectId);
window.sessionStorage.setItem(`${ROCK_DEFAULT_PREFIX}${projectId}`, String(offsetM));
} catch {
/* 세션 저장 실패는 무시. */
}
} }
/** 기억해 둔 암반 경계 기본 오프셋(m). 없으면 null. */ /** 기억해 둔 암반 경계 기본 오프셋(m). 없으면 null. */
export function readRockBoundaryDefault(projectId: string): number | null { export function readRockBoundaryDefault(projectId: string): number | null {
try { const raw = readStateRaw("rock-boundary-default", projectId);
const raw = window.sessionStorage.getItem(`${ROCK_DEFAULT_PREFIX}${projectId}`); const parsed = raw === null ? Number.NaN : Number(raw);
const parsed = raw === null ? Number.NaN : Number(raw); return Number.isFinite(parsed) ? parsed : null;
return Number.isFinite(parsed) ? parsed : null;
} catch {
return null;
}
} }
/** /**
@@ -105,11 +81,7 @@ export function effectiveStandardCross(projectId: string): StandardCrossSection
/** 표준횡단 세션 편집값을 버린다 — B05 [초기화]가 부른다(정의처를 여기 하나로 둔다). */ /** 표준횡단 세션 편집값을 버린다 — B05 [초기화]가 부른다(정의처를 여기 하나로 둔다). */
export function clearStandardCrossSession(projectId: string): void { export function clearStandardCrossSession(projectId: string): void {
try { clearState("std-cross", projectId);
window.sessionStorage.removeItem(sessionKey(projectId));
} catch {
/* 세션 접근이 막혀도 초기화는 계속한다. */
}
} }
/** config 기본값을 깊은 복사해 편집용 초기 상태로 만든다. */ /** config 기본값을 깊은 복사해 편집용 초기 상태로 만든다. */
@@ -124,20 +96,11 @@ function cloneDefaults(defaults: StandardCrossSection): StandardCrossSection {
* ** ** . * ** ** .
*/ */
export function readStandardCrossSession(projectId: string): StandardCrossSection | null { export function readStandardCrossSession(projectId: string): StandardCrossSection | null {
try { return readState<StandardCrossSection>("std-cross", projectId);
const raw = window.sessionStorage.getItem(sessionKey(projectId));
return raw ? (JSON.parse(raw) as StandardCrossSection) : null;
} catch {
return null;
}
} }
function writeSession(projectId: string, value: StandardCrossSection): void { function writeSession(projectId: string, value: StandardCrossSection): void {
try { writeState("std-cross", value, projectId);
window.sessionStorage.setItem(sessionKey(projectId), JSON.stringify(value));
} catch {
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
}
} }
export interface StandardPanelController { export interface StandardPanelController {