사용자 확정(2026-09-06): 조작은 캐시에 쌓이고 [저장]·[확정]에서만 영구저장소에 나간다. 카드 버튼(지반유형·단면유형·측구·포장·2단 비탈)은 누를 때마다 POST cross-design 이 나가 서버가 계산하고 바로 저장하고 있었다. - 세션 초안 crossdesign 신설(B06_Section_Cross_Design_Session) — 선택값만 담는다. - 계산은 브라우저 재계산 창구 하나로 돌린다(B05·B06 공용). - 재계산 때 세션 선택이 정본보다 우선 — 새로고침 뒤에도 고른 값이 남는다. - [저장]·[확정] patch 에 선택값 + 전 측점 면적을 실어 보낸다. 카드 버튼을 바꾸면 구조물이 없는 측점 면적도 달라지므로 구조물 측점만 보내면 수량이 어긋난다. - 구조물 목록 주석 정정(이미 초안 방식인데 옛 주석만 남아 있었다). 검증: tsc --noEmit 통과, pytest 389 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
312 lines
14 KiB
TypeScript
312 lines
14 KiB
TypeScript
/* =============================================================================
|
|
* A00_Common/b_page_state.ts
|
|
* 화면 상태 보관소 **한 곳** — B05·B06 이 한 페이지처럼 움직이기 위한 뼈대
|
|
* (2026-09-06 사용자 지시).
|
|
*
|
|
* 왜 필요한가 — 세션 접근이 19개 파일 64곳에 흩어져 있었고 키 이름이 네 갈래
|
|
* (`b05:`, `b06:`, `b05-route-`, `aislo:`)로 제각각이었다. 같은 설계값인데 키가
|
|
* 페이지 이름으로 갈려 두 화면이 서로의 값을 못 보는 자리도 있었다. 값마다 **어느 통에
|
|
* 속하는지**를 아래 등록표에 한 줄로 적어 두고, 저장·복원·비우기가 전부 그 표만 보고
|
|
* 움직이게 한다. 나중에 캐시↔즉시를 바꿀 때도 표 한 줄만 고치면 된다.
|
|
*
|
|
* 통은 넷이다.
|
|
* ① pref 화면 취향 — 패널 열림·높이, 보기 토글. **계정**에 붙는다(다른 PC에서도 같은
|
|
* 배치). 세션은 그 값의 사본이라 서버가 없어도 화면은 돈다.
|
|
* ② draft 설계 초안 — 사용자가 만진 설계값. [저장]·[확정] 때만 서버로 간다.
|
|
* ③ (즉시) 누르는 순간 다시 계산을 부르는 명령. 여기 쌓지 않는다 — 표에도 없다.
|
|
* ④ result 계산 결과 — 서버·브라우저가 만들어 낸 값. 저장 대상이 아니고, 입력이
|
|
* 바뀌면 버리고 다시 만든다. 페이지를 오갈 때 다시 부르지 않으려고 둔다.
|
|
*
|
|
* 키 형식은 `aislo:{통}:{이름}:{프로젝트}[:{노선}]` 하나다. **페이지 이름을 키에 넣지
|
|
* 않는다** — ②·④는 두 화면이 같은 키를 쓴다.
|
|
* ========================================================================== */
|
|
|
|
/** 값이 속한 통. `pref`·`draft`·`result` 셋만 저장소를 쓴다(즉시 반영은 표에 없다). */
|
|
export type StateBucket = "pref" | "draft" | "result";
|
|
|
|
/** 값을 가르는 범위 — 키에 무엇을 덧붙일지 정한다. */
|
|
export type StateScope = "global" | "project" | "route";
|
|
|
|
export interface StateEntry {
|
|
bucket: StateBucket;
|
|
scope: StateScope;
|
|
/** 형식이 바뀌면 올린다 — 옛 값을 읽지 않고 기본값으로 시작한다. */
|
|
version?: number;
|
|
/** 옛 키(있으면 한 번 읽어 옮기고 지운다). `project`·`route` 범위는 함수로 받는다. */
|
|
legacy?: (projectId?: string, routeId?: number | string) => string;
|
|
}
|
|
|
|
/**
|
|
* 등록표 — **새 값은 여기 줄부터 넣고 코드를 쓴다.**
|
|
*
|
|
* `scope: "global"` 은 프로젝트를 가리지 않는 화면 취향이다(패널 높이 등). 설계값은
|
|
* 반드시 `project` 또는 `route` 범위를 쓴다 — 프로젝트를 옮겼는데 앞 프로젝트의 조작이
|
|
* 남으면 안 된다.
|
|
*/
|
|
export const STATE_REGISTRY = {
|
|
/* ── ① 화면 취향 ─────────────────────────────────────────────────────── */
|
|
"profile-collapsed": {
|
|
bucket: "pref",
|
|
scope: "global",
|
|
legacy: () => "b05-route-profile-collapsed",
|
|
},
|
|
"profile-height": { bucket: "pref", scope: "global", legacy: () => "b05-route-profile-height" },
|
|
"drainage-collapsed": {
|
|
bucket: "pref",
|
|
scope: "global",
|
|
legacy: () => "b05-route-drainage-collapsed",
|
|
},
|
|
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
|
|
"masshaul-open": {
|
|
bucket: "pref",
|
|
scope: "global",
|
|
legacy: () => "b05-route-profile-masshaul-open",
|
|
},
|
|
"masshaul-height": {
|
|
bucket: "pref",
|
|
scope: "global",
|
|
legacy: () => "b05-route-profile-masshaul-height",
|
|
},
|
|
/** 유토곡선 범례 — B05·B06 이 같은 값을 본다(예전에도 키를 공유했다). */
|
|
"masshaul-visible": {
|
|
bucket: "pref",
|
|
scope: "global",
|
|
version: 5,
|
|
legacy: () => "b06:masshaul-visible-v4",
|
|
},
|
|
"table-open": { bucket: "pref", scope: "global", legacy: () => "b05:profile:table:open" },
|
|
"table-height": { bucket: "pref", scope: "global", legacy: () => "b05:profile:table:height" },
|
|
"section-panel-collapsed": {
|
|
bucket: "pref",
|
|
scope: "global",
|
|
legacy: () => "b06:profile-panel-collapsed",
|
|
},
|
|
"section-panel-height": {
|
|
bucket: "pref",
|
|
scope: "global",
|
|
legacy: () => "b06:profile-panel-height",
|
|
},
|
|
|
|
/* ── ② 설계 초안 ─────────────────────────────────────────────────────── */
|
|
/** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */
|
|
structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` },
|
|
/** 3D 램프로 바꾼 측점 상단측(측구 방향). */
|
|
uphill: { bucket: "draft", scope: "project", legacy: (p) => `b05:uphill:${p}` },
|
|
/* `pipes`(옛 `b05:pipes`)는 2026-09-06 에 뺐다 — 읽는 곳도 쓰는 곳도 없었다.
|
|
관 위치의 정본은 `pipe_points.json` 이고, 화면은 [저장] 때 `savePipes()` 로 바로
|
|
내보낸다. 초안처럼 보이는 이름만 남아 대응표에서 「저장 자리 없음」으로 잡혔다. */
|
|
/** B05 에서 고른 구조물을 B06 이 이어받는 자리 — 예전 `aislo:structure-pick:*`. */
|
|
"structure-pick": {
|
|
bucket: "draft",
|
|
scope: "project",
|
|
legacy: (p) => `aislo:structure-pick:${p}`,
|
|
},
|
|
/** 배수관·암거 조정창에서 예약한 옵션 값. */
|
|
culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` },
|
|
/** 배수관 이동(측점 옮김) 예약. */
|
|
culvertmove: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertmove:${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}` },
|
|
/** 표시 반폭 — 사용자가 고른 값이라 초안이되, 바꾸면 횡단 재생성(③)을 함께 부른다. */
|
|
"cross-display": {
|
|
bucket: "draft",
|
|
scope: "route",
|
|
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}` },
|
|
extraspan: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:extraspan:${p}:${r}` },
|
|
/** 횡단 카드 버튼(지반유형·단면유형·측구·포장·2단 비탈) 선택 — 2026-09-06.
|
|
* 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고
|
|
* [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */
|
|
crossdesign: { bucket: "draft", scope: "route" },
|
|
|
|
/* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */
|
|
/** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */
|
|
latest: { bucket: "result", scope: "project", legacy: (p) => `b05:latest:${p}` },
|
|
/** 종횡단 설정값(config 상수 + 프로젝트 임도 종류 + 노선 id). B05·B06 이 같은 값을
|
|
* 받아 쓰므로 화면을 오갈 때마다 다시 묻지 않는다(2026-09-06 호출 정리).
|
|
* 노선이 바뀌면 `latest` 와 함께 버린다. */
|
|
"section-context": { bucket: "result", scope: "project" },
|
|
/** 횡단 상세 응답 — 예전에는 메모리에만 있어 페이지를 떠나면 사라졌다. */
|
|
"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>;
|
|
|
|
export type StateName = keyof typeof STATE_REGISTRY;
|
|
|
|
const warnedNames = new Set<string>();
|
|
|
|
/**
|
|
* 등록표에서 한 줄을 꺼낸다. 표에 없는 이름이면 **화면을 죽이지 않고** null 을 돌려준다 —
|
|
* 그 값만 저장이 안 될 뿐 페이지는 그대로 선다(2026-09-06: `extraspan` 이 빠져 B06 이
|
|
* 통째로 안 뜬 적이 있다). 대신 콘솔에 한 번 알려 표에 줄을 넣게 한다.
|
|
*/
|
|
function entryOf(name: StateName): StateEntry | null {
|
|
const entry = STATE_REGISTRY[name] as StateEntry | undefined;
|
|
if (entry) return entry;
|
|
if (!warnedNames.has(name)) {
|
|
warnedNames.add(name);
|
|
console.warn(`[page-state] 등록표에 없는 이름입니다 — 저장하지 않습니다: ${name}`);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 저장소 키. 범위가 요구하는 값이 없으면 `null` — 부를 쪽은 그때 저장을 건너뛴다
|
|
* (프로젝트를 아직 모를 때 전역 키에 적으면 다음 프로젝트가 그 값을 물려받는다).
|
|
*/
|
|
export function stateKey(
|
|
name: StateName,
|
|
projectId?: string | null,
|
|
routeId?: number | string | null,
|
|
): string | null {
|
|
const entry = entryOf(name);
|
|
if (!entry) return null;
|
|
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
|
|
const head = `aislo:${entry.bucket}:${name}${suffix}`;
|
|
if (entry.scope === "global") return head;
|
|
if (!projectId) return null;
|
|
if (entry.scope === "project") return `${head}:${projectId}`;
|
|
if (routeId === null || routeId === undefined) return null;
|
|
return `${head}:${projectId}:${routeId}`;
|
|
}
|
|
|
|
/** 세션 접근은 저장소가 막힌 브라우저(사생활 보호)에서 던진다 — 전부 조용히 넘긴다. */
|
|
function readRaw(key: string): string | null {
|
|
try {
|
|
return window.sessionStorage.getItem(key);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeRaw(key: string, value: string | null): void {
|
|
try {
|
|
if (value === null) window.sessionStorage.removeItem(key);
|
|
else window.sessionStorage.setItem(key, value);
|
|
} catch {
|
|
/* 무시 — 값은 화면 메모리에 남는다. */
|
|
}
|
|
}
|
|
|
|
/** 옛 키에 남은 값을 새 키로 한 번 옮긴다(옮기고 나면 옛 키는 지운다). */
|
|
function migrate(
|
|
name: StateName,
|
|
key: string,
|
|
projectId?: string | null,
|
|
routeId?: number | string | null,
|
|
): void {
|
|
const legacy = entryOf(name)?.legacy?.(projectId ?? undefined, routeId ?? undefined);
|
|
if (!legacy || legacy === key) return;
|
|
const old = readRaw(legacy);
|
|
if (old !== null && readRaw(key) === null) writeRaw(key, old);
|
|
if (old !== null) writeRaw(legacy, null);
|
|
}
|
|
|
|
/** 문자열 그대로 읽는다(숫자·참거짓처럼 JSON 이 아닌 값). */
|
|
export function readStateRaw(
|
|
name: StateName,
|
|
projectId?: string | null,
|
|
routeId?: number | string | null,
|
|
): string | null {
|
|
const key = stateKey(name, projectId, routeId);
|
|
if (!key) return null;
|
|
migrate(name, key, projectId, routeId);
|
|
return readRaw(key);
|
|
}
|
|
|
|
export function writeStateRaw(
|
|
name: StateName,
|
|
value: string | null,
|
|
projectId?: string | null,
|
|
routeId?: number | string | null,
|
|
): void {
|
|
const key = stateKey(name, projectId, routeId);
|
|
if (key) writeRaw(key, value);
|
|
}
|
|
|
|
/** JSON 값을 읽는다. 없거나 손상됐으면 `null` — 부를 쪽이 기본값으로 시작한다. */
|
|
export function readState<T>(
|
|
name: StateName,
|
|
projectId?: string | null,
|
|
routeId?: number | string | null,
|
|
): T | null {
|
|
const raw = readStateRaw(name, projectId, routeId);
|
|
if (raw === null) return null;
|
|
try {
|
|
return JSON.parse(raw) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function writeState(
|
|
name: StateName,
|
|
value: unknown,
|
|
projectId?: string | null,
|
|
routeId?: number | string | null,
|
|
): void {
|
|
writeStateRaw(name, value === null ? null : JSON.stringify(value), projectId, routeId);
|
|
}
|
|
|
|
export function clearState(
|
|
name: StateName,
|
|
projectId?: string | null,
|
|
routeId?: number | string | null,
|
|
): void {
|
|
writeStateRaw(name, null, projectId, routeId);
|
|
}
|
|
|
|
/** 등록표에서 한 통에 속한 이름만 고른다. */
|
|
export function namesInBucket(bucket: StateBucket): StateName[] {
|
|
return (Object.keys(STATE_REGISTRY) as StateName[]).filter(
|
|
(name) => entryOf(name)?.bucket === bucket,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 초안을 통째로 비운다 — **[저장]·[확정]·[초기화]·노선 변경 뒤 여기 한 곳**만 부른다
|
|
* (예전에는 파일마다 따로 지웠다). 노선을 모르면 노선 범위 초안은 남는다.
|
|
*/
|
|
export function clearDrafts(projectId: string | null, routeId?: number | string | null): void {
|
|
namesInBucket("draft").forEach((name) => clearState(name, projectId, routeId));
|
|
}
|
|
|
|
/** 계산 결과를 통째로 버린다 — 입력(설계값·노선)이 바뀌어 다시 만들어야 할 때. */
|
|
export function clearResults(projectId: string | null, routeId?: number | string | null): void {
|
|
namesInBucket("result").forEach((name) => clearState(name, projectId, routeId));
|
|
}
|
|
|
|
/**
|
|
* 지금 이 브라우저에 쌓인 초안이 있는가 — [저장] 버튼의 미저장 표시에 쓴다.
|
|
* 값이 빈 객체(`{}`)면 없는 것으로 본다.
|
|
*/
|
|
export function hasDrafts(projectId: string | null, routeId?: number | string | null): boolean {
|
|
return namesInBucket("draft").some((name) => {
|
|
const raw = readStateRaw(name, projectId, routeId);
|
|
return raw !== null && raw !== "{}" && raw !== "[]" && raw !== "";
|
|
});
|
|
}
|