Files
Aislo/A00_Common/design/design_store.ts
T
eomsangdonandClaude Opus 5.5 281c71b55b refactor(logic): 348-1 순수 계산 TS 49 파일 <페이지>/logic/ 로 옮김 — 이름 앞머리 그대로 · 동작 고침 없음
- 옮김: B04 2 · B05 23 · B06 11 · A00_Common/design 13 (9,299 줄) · 이미 logic/ 에 있던 16 파일 그대로
- 안 옮김: 묶음 입구(package.json build:*) · 파이썬 · 순수지만 계산 아닌 8 (보기 결선 · 콜백 · API 타입 · 갈아끼움 알림판)
- import 266 줄(142 파일) · 시험 · 주석 · 짝 표시 문자열 경로 고침 · B05 · B06 CLAUDE.md 짝 줄 logic/
- 시험 도우미 새 경로: 가짜 require 열쇠 · B05()/B06() 부름 · 형제 import 자리 · tsc 묶어 돌리는 시험 · 정규식 · rglob
- design_view_cross 줄 한도 시험 685 → 688 (긴 import 경로 줄바꿈 3줄)
- 확인: typecheck 0 · 서버 묶음 넷(corridor · server-calc · b07-extent · b07-note) 다시 지어 코드 같음(//#region 경로 주석만 다름) · 관련 시험 270 파일 — 남은 실패 19 는 옮기기 전과 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 13:44:50 +09:00

238 lines
10 KiB
TypeScript

/* =============================================================================
* 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<K extends DesignStoreKey>(key: K): DesignStoreValues[K];
set<K extends DesignStoreKey>(key: K, value: DesignStoreValues[K]): void;
/** 가게로 쓸 때마다 부른다. 돌려받은 함수로 끊는다. */
subscribe<K extends DesignStoreKey>(
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<T> {
/** 듣는 쪽을 묶는 이름 — 같은 캐시 칸이면 같은 이름. 칸을 못 정하면 null. */
id: string | null;
read(): T | null;
write(value: T | null): void;
}
function slotOf<K extends DesignStoreKey>(
key: K,
projectId: string | null,
routeId: number | null,
): Slot<DesignStoreValues[K]> {
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<Partial<AlignmentEdits>>("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<V>(key as MemoryKey, projectId);
// 등록부 이름 — 가게 이름과 캐시 이름이 다른 것만 적는다.
const name = (
{
structures: "structures",
pipes: "pipes",
sectionDetail: "section-detail",
selection: "selection",
routeDraft: "route-draft",
} as const
)[key as Exclude<DesignStoreKey, "alignmentDraft" | MemoryKey>];
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<V>(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<Record<DesignStoreKey, StateName | null>>;
type MemoryKey = keyof typeof MEMORY_SLOTS;
/** 멈춘 뒤 세션에 한 번 쓰기까지(ms). */
export const PERSIST_DELAY_MS = 1000;
const memory = new Map<string, unknown>();
const persistTimers = new Map<
string,
{ timer: ReturnType<typeof setTimeout>; 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<T>(key: MemoryKey, projectId: string | null): Slot<T> {
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<T>(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<string, Set<(value: unknown) => 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);
},
};
}