/* ============================================================================= * 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}` }, /** B05 배수유역도에서 고친 관 목록(추가·이동·삭제) — 2026-09-06 되살림. * 예전에는 패널 메모리에만 있어 B06 으로 넘어가면 편집이 사라졌다(대응표 조사). * 정본은 `pipe_points.json` 이고, 이 초안은 [저장]·[확정]에서 `flushPendingPipes` 가 * 내보낸 뒤 비운다. */ pipes: { bucket: "draft", scope: "project", legacy: (p) => `b05:pipes:${p}` }, /** 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}` }, /** 표준 횡단면 설정 패널의 편집값. * * ⚠ **[저장]·[확정] 뒤에도 지우지 않는다**(2026-09-07 확인). 다른 초안과 달리 이 값은 * 브라우저가 가진 **유일한** 사용자 표준단면이다 — `sections/context` 가 주는 * `standard_cross_section` 은 저장분이 아니라 **config 기본값**이고(`B06_Section_Router.py:132`), * 저장분(`stored_standard_cross_section`)은 서버 안에서만 쓰인다. 그래서 비우면 브라우저 * 횡단 계산이 그 순간 config 기본값으로 되돌아간다. [초기화]에서만 버린다 * (`clearStandardCrossSession`). */ "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; export type StateName = keyof typeof STATE_REGISTRY; const warnedNames = new Set(); /** * 등록표에서 한 줄을 꺼낸다. 표에 없는 이름이면 **화면을 죽이지 않고** 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}`; } /** * 어느 저장소에 둘지 — **화면 취향(`pref`)만 `localStorage`**, 나머지는 세션이다. * * 2026-09-07 확인 — 취향까지 세션에 있어 **탭을 새로 열거나 브라우저를 껐다 켤 때마다** * 패널 높이·접힘이 초기화됐다(계획서에는 「PC 를 바꾸면」으로 적혀 있었으나 실제 불편은 * 훨씬 잦았다). 취향은 설계 데이터가 아니므로 5장 「캐시=세션」 규칙에 걸리지 않는다 — * **설계값·초안·계산 결과는 세션 그대로** 둔다(그 경계를 흐리지 말 것). */ function storageFor(key: string): Storage { return key.startsWith("aislo:pref:") ? window.localStorage : window.sessionStorage; } /** * **키만 아는 자리**를 위한 읽기·쓰기 창구 — 등록표 이름 대신 완성된 키를 든 곳이 쓴다 * (`ui_template_resizer`·`_overlay`·`_overlay_drag`, 조정창 저장소). * * 저장소를 직접 고르게 두면 두 가지가 샜다 — 취향이 세션에 남아 탭마다 초기화됐고 * (2026-09-07), 계정 올려보내기도 안 걸렸다. **저장소 선택과 올려보내기를 여기 한 곳**에 둔다. */ export function readByKey(key: string): string | null { return readRaw(key); } export function writeByKey(key: string, value: string | null): void { writeRaw(key, value); } /** * 세션에 남아 있던 옛 취향을 **한 번에 브라우저 저장소로 옮긴다**(2026-09-07). * * 취향을 쓰는 화면 부품(높이 조절자·접이식 패널)은 키만 들고 저장소를 직접 만지므로 * 읽는 자리마다 건져 올리는 방식으로는 **높이 값이 안 옮겨졌다**(실측: 접힘 4개는 옮겨지고 * 높이 3개는 세션에 남음). 그래서 첫 로드 때 한 번 쓸어 옮긴다 — 쓰던 패널 크기가 * 이 변경 때문에 초기화되지 않게 한다. 여러 번 불려도 안전하다. */ function liftPrefsToLocalStorage(): void { try { const stale = Object.keys(window.sessionStorage).filter((key) => key.startsWith("aislo:pref:")); for (const key of stale) { const value = window.sessionStorage.getItem(key); if (value !== null && window.localStorage.getItem(key) === null) { window.localStorage.setItem(key, value); } window.sessionStorage.removeItem(key); } } catch { /* 저장소가 막힌 브라우저 — 취향은 이번 세션 동안 화면 메모리로만 산다. */ } } liftPrefsToLocalStorage(); /* ── 계정에 붙는 화면 취향 (2026-09-07 사용자 승인) ───────────────────────────── * 브라우저에 남긴 값 위에 **계정 값을 얹는다** — PC 를 바꿔도 같은 배치가 되게. * 규칙 셋: * · 서버가 없거나 못 읽으면 **로컬 값으로 그대로 돈다**(계획서 원문). * · 서버에 있는 키만 덮는다 — 서버가 비었다고 로컬을 지우지 않는다. * · 취향이 바뀌면 잠시 모아 한 번에 올린다. **설계값은 여기 오지 않는다** — * 자동저장 금지(CLAUDE.md 5장)는 설계값 규칙이고, 화면 배치는 [저장] 단추가 없다. * ------------------------------------------------------------------------- */ const PREFS_ENDPOINT = "/api/dashboard/me/ui-prefs"; const PREFS_PUSH_DELAY_MS = 1500; let applyingServerPrefs = false; let prefsPushTimer: ReturnType | null = null; function localPrefs(): Record { const out: Record = {}; try { for (const key of Object.keys(window.localStorage)) { if (!key.startsWith("aislo:pref:")) continue; const value = window.localStorage.getItem(key); if (value !== null) out[key] = value; } } catch { /* 저장소가 막힌 브라우저 — 올릴 것이 없다. */ } return out; } /** 취향이 바뀌면 잠시 모아 한 번에 올린다. 실패는 조용히 넘긴다(다음 변경 때 다시 올라간다). */ function pushUiPrefs(): void { if (applyingServerPrefs) return; if (prefsPushTimer) clearTimeout(prefsPushTimer); prefsPushTimer = setTimeout(() => { prefsPushTimer = null; void fetch(PREFS_ENDPOINT, { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prefs: localPrefs() }), }).catch(() => undefined); }, PREFS_PUSH_DELAY_MS); } /** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번. */ export async function syncUiPrefs(): Promise { let stored: Record = {}; try { const response = await fetch(PREFS_ENDPOINT, { credentials: "include" }); if (!response.ok) return; const body = (await response.json()) as { prefs?: Record }; stored = body.prefs ?? {}; } catch { return; // 서버가 없으면 로컬 값으로 그대로 돈다. } applyingServerPrefs = true; try { for (const [key, value] of Object.entries(stored)) { if (!key.startsWith("aislo:pref:") || typeof value !== "string") continue; window.localStorage.setItem(key, value); } } catch { /* 저장소가 막힌 브라우저 — 이번 세션은 화면 메모리로 돈다. */ } finally { applyingServerPrefs = false; } } /** 저장소 접근은 막힌 브라우저(사생활 보호)에서 던진다 — 전부 조용히 넘긴다. */ function readRaw(key: string): string | null { try { return storageFor(key).getItem(key); } catch { return null; } } function writeRaw(key: string, value: string | null): void { try { const store = storageFor(key); if (value === null) store.removeItem(key); else store.setItem(key, value); } catch { /* 무시 — 값은 화면 메모리에 남는다. */ } if (key.startsWith("aislo:pref:")) pushUiPrefs(); } /** 옛 키에 남은 값을 새 키로 한 번 옮긴다(옮기고 나면 옛 키는 지운다). */ 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( 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 !== ""; }); }