/* ============================================================================= * A00_Common/design/design_store.ts * B05 · B06 설계 상태 **한 곳**(계획서 24-1) — 두 보기가 같은 값을 같은 창구로 읽고 쓴다. * * const store = useDesignStore(projectId, routeId); * store.get("pipes") · store.set("pipes", next) · store.subscribe("pipes", fn) * * 밑은 지금 캐시 그대로다 — 값마다 `b_page_state` 등록 키 하나(새 키는 등록부에 먼저). * 가게는 값을 따로 들고 있지 않는다: 읽을 때마다 캐시를 읽고, 쓸 때 캐시에 쓴 뒤 듣는 쪽을 * 부른다. 그래서 가게를 거치지 않고 캐시를 고친 옛 자리가 남아 있어도 읽는 값은 갈리지 않는다 * (다만 그 자리는 듣는 쪽을 못 부른다 — 쓰는 길은 가게로 옮길 것). * * `alignmentDraft` 는 등록부 `alignment-draft`(노선 칸)다 — 옛 키(`b05-profile-alignment-draft:{노선}`) * 값은 처음 읽을 때 옮겨 받는다. B05 되돌리기(`_Profile_History`)도 이 칸을 사진 찍는다(24-2c). * 부르는 쪽이 프로젝트를 모르면(`null`) 지금 연 프로젝트 칸을 쓴다. * * 계산 묶음 칸(344-4 · 모양 = `design_pipeline_types.ts`) — `routeDraft` 는 초안(세션 즉시) · * `stations` · `profile` · `footprint` 는 **메모리 우선**(결과가 커서 끌기마다 세션에 쓰지 않음 · * 멈춘 뒤 `PERSIST_DELAY_MS` 한 번 · 키가 지워지면 메모리도 버림) · `generation` 은 메모리만. * ========================================================================== */ import { onStateRemoved, readState, stateKey, writeState } from "../b_page_state"; import type { StateName } from "../b_page_state"; import { CURRENT_PROJECT_ID_KEY } from "../../config/config_frontend"; import type { StructureInstance } from "../../B05_Profile/B05_Profile_Api_Structures"; import type { DetailPipeInput } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { AlignmentEdits } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; import type { ProfileAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import type { PipelineStation, PlanFootprint, RouteDraft } from "./logic/design_pipeline_types"; /** 한 페이지의 두 보기(계획서 24-1). */ export type DesignView = "profile" | "cross"; /** 고른 자리 — 측점 · 구조물. 둘 다 비면 고른 것이 없다. */ export interface DesignSelection { chainageM: number | null; structureId: string | null; } /** 가게가 드는 값 — 없으면 null(= 만진 적 없음 · 저장분을 쓸 것). */ export interface DesignStoreValues { /** 미저장 구조물 목록(정본 `structures.json`). */ structures: StructureInstance[] | null; /** 미저장 관 목록(정본 `pipe_points.json`) — 주인은 이 가게 하나다. */ pipes: DetailPipeInput[] | null; /** 미저장 계획고 편집(▲▼ · 곡선반경). 노선이 있어야 한다. */ alignmentDraft: AlignmentEdits | null; /** 종횡단 상세 — 세션 사본. 화면이 함께 쥐는 객체는 `B06_Section_Section_Store`. */ sectionDetail: SectionDetailResponse | null; /** 고른 측점 · 구조물. */ selection: DesignSelection | null; /** 노선 초안(노드 · 곡선 · 반경 · 스냅 · 지형 · rev) — 노선 편집기가 놓을 때 씀. */ routeDraft: RouteDraft | null; /** 계산 묶음 결과 — 측점(지반 채움) · 종단 · 평면 발자국 · 지금 차례 번호. */ stations: PipelineStation[] | null; profile: ProfileAlignment | null; footprint: PlanFootprint | null; generation: number | null; } export type DesignStoreKey = keyof DesignStoreValues; export interface DesignStore { get(key: K): DesignStoreValues[K]; set(key: K, value: DesignStoreValues[K]): void; /** 가게로 쓸 때마다 부른다. 돌려받은 함수로 끊는다. */ subscribe( key: K, fn: (value: DesignStoreValues[K]) => void, ): () => void; } /** 옛 계획고 초안 키 — 위 머리 주석 참조(남은 옛 값을 쓸어 낼 때만 쓴다). */ export const ALIGNMENT_DRAFT_KEY_PREFIX = "b05-profile-alignment-draft"; /** 계획고 초안 칸의 프로젝트 — 부르는 쪽이 모르면 지금 연 프로젝트, 그것도 없으면 `-` * (노선 번호는 프로젝트를 건너 겹치지 않는다). */ export function draftProjectId(projectId: string | null = null): string { if (projectId) return projectId; try { return window.localStorage.getItem(CURRENT_PROJECT_ID_KEY) || "-"; } catch { return "-"; } } interface Slot { /** 듣는 쪽을 묶는 이름 — 같은 캐시 칸이면 같은 이름. 칸을 못 정하면 null. */ id: string | null; read(): T | null; write(value: T | null): void; } function slotOf( key: K, projectId: string | null, routeId: number | null, ): Slot { type V = DesignStoreValues[K]; if (key === "alignmentDraft") { const project = draftProjectId(projectId); if (routeId === null) return { id: null, read: () => null, write: () => undefined }; return { id: `alignment-draft:${project}:${routeId}`, read: () => { const parsed = readState>("alignment-draft", project, routeId); if (!parsed) return null; return { station_offsets: parsed.station_offsets ?? {}, curve_radii: parsed.curve_radii ?? {}, } as V; }, write: (value) => writeState("alignment-draft", value, project, routeId), }; } if (key in MEMORY_SLOTS) return memorySlot(key as MemoryKey, projectId); // 등록부 이름 — 가게 이름과 캐시 이름이 다른 것만 적는다. const name = ( { structures: "structures", pipes: "pipes", sectionDetail: "section-detail", selection: "selection", routeDraft: "route-draft", } as const )[key as Exclude]; const routeScoped = name === "section-detail"; if (!projectId || (routeScoped && routeId === null)) { return { id: null, read: () => null, write: () => undefined }; } const scopeRoute = routeScoped ? routeId : undefined; return { id: `${name}:${projectId}${routeScoped ? `:${routeId}` : ""}`, read: () => readState(name, projectId, scopeRoute), write: (value) => writeState(name, value, projectId, scopeRoute), }; } /** 메모리 우선 칸 → 등록부 이름(null = 메모리만). */ const MEMORY_SLOTS = { stations: "design-stations", profile: "design-profile", footprint: "design-footprint", generation: null, } as const satisfies Partial>; type MemoryKey = keyof typeof MEMORY_SLOTS; /** 멈춘 뒤 세션에 한 번 쓰기까지(ms). */ export const PERSIST_DELAY_MS = 1000; const memory = new Map(); const persistTimers = new Map< string, { timer: ReturnType; write: () => void } >(); function cancelPersist(storageKey: string): void { const pending = persistTimers.get(storageKey); if (pending) clearTimeout(pending.timer); persistTimers.delete(storageKey); } onStateRemoved((storageKey) => { memory.delete(storageKey); cancelPersist(storageKey); }); /** 미뤄 둔 세션 쓰기를 지금 — [저장] 앞 · 시험. */ export function flushDesignStore(): void { const writes = [...persistTimers.values()]; persistTimers.forEach((pending) => clearTimeout(pending.timer)); persistTimers.clear(); writes.forEach((pending) => pending.write()); } function memorySlot(key: MemoryKey, projectId: string | null): Slot { const name = MEMORY_SLOTS[key]; const storageKey = name ? stateKey(name, projectId) : projectId && `memory:${key}:${projectId}`; if (!projectId || !storageKey) return { id: null, read: () => null, write: () => undefined }; return { id: storageKey, read: () => { if (memory.has(storageKey)) return memory.get(storageKey) as T; const stored = name ? readState(name, projectId) : null; if (stored !== null) memory.set(storageKey, stored); return stored; }, write: (value) => { if (value === null) { memory.delete(storageKey); cancelPersist(storageKey); if (name) writeState(name, null, projectId); return; } memory.set(storageKey, value); if (!name) return; cancelPersist(storageKey); const write = (): void => { persistTimers.delete(storageKey); writeState(name, memory.get(storageKey) ?? null, projectId); }; persistTimers.set(storageKey, { timer: setTimeout(write, PERSIST_DELAY_MS), write }); }, }; } const listeners = new Map void>>(); /** * 그 프로젝트 · 노선의 가게. 부를 때마다 새로 만들어도 된다 — 값도 듣는 쪽도 가게 밖 * (캐시 · 모듈 표)에 있어서 같은 칸을 가리키면 같은 가게다. * * 노선을 모르면 `routeId = null` — 노선 칸(`alignmentDraft` · `sectionDetail`)은 비어 보이고 * 쓰기는 버려진다. 프로젝트를 모르면 프로젝트 칸이 그렇다. */ export function useDesignStore(projectId: string | null, routeId: number | null): DesignStore { return { get(key) { return slotOf(key, projectId, routeId).read() as DesignStoreValues[typeof key]; }, set(key, value) { const slot = slotOf(key, projectId, routeId); slot.write(value); if (slot.id) listeners.get(`${key}|${slot.id}`)?.forEach((fn) => fn(value)); }, subscribe(key, fn) { const id = slotOf(key, projectId, routeId).id; if (!id) return () => undefined; const bucket = `${key}|${id}`; const set = listeners.get(bucket) ?? new Set(); listeners.set(bucket, set); const listener = fn as (value: unknown) => void; set.add(listener); return () => set.delete(listener); }, }; }