Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_7

This commit is contained in:
2026-09-29 20:17:02 +09:00
13 changed files with 815 additions and 234 deletions
+2
View File
@@ -110,6 +110,8 @@ export const STATE_REGISTRY = {
scope: "project",
legacy: (p) => `aislo:structure-pick:${p}`,
},
/** 설계 가게(`design/design_store`)의 고른 측점 · 구조물 — `{chainageM, structureId}`. */
selection: { bucket: "draft", scope: "project" },
/** 배수관·암거 조정창에서 예약한 옵션 값. */
culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` },
/** 배수관 이동(측점 옮김) 예약. */
+145
View File
@@ -0,0 +1,145 @@
/* =============================================================================
* A00_Common/design/design_save.ts
* B05 · B06 **저장 한 줄기**(계획서 24-1) — [저장] · [확정]이 어느 보기에서 눌려도 같은 차례.
*
* 관 → 구조물 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단
*
* 차례는 여기 한 곳에만 둔다. 보기마다 다른 것은 **화면이 쥔 값으로 한 번 더 맞추는 일**
* (B05 배수유역도 목록 · 구조물 목록 새로 받기 · 종단 패널 다시 그리기)과 횡단 저장 본문뿐이라,
* 그 몫만 `steps` · `cross` 로 받는다. 서버 라우트는 그대로다.
*
* ⚠ 차례를 바꾸면 결과가 갈린다 — 관 목록은 스냅샷이라 구간값(암거 옵션)보다 먼저, 상단측은
* 횡단 patch 보다 먼저 나가야 사용자 수정이 맨 위에 얹힌다(2026-09-06 · 09-12).
* 영구저장은 여기서만 한다(CLAUDE.md 5장 — 자동저장 없음).
* ========================================================================== */
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import { flushPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { flushPendingStructures } from "../../B05_Profile/B05_Profile_Api_Structures";
import {
flushUphillOverrides,
saveProfileAlignment,
} from "../../B05_Profile/B05_Profile_Api_Fetch";
import {
markAlignmentSaved,
pendingAlignment,
} from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
import { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options";
import { stateKey } from "../b_page_state";
/** 저장 차례 — 이 순서 그대로 돈다. */
export const DESIGN_SAVE_ORDER = [
"pipes",
"structures",
"alignment",
"culvertOptions",
"uphill",
"cross",
] as const;
export type DesignSaveStep = (typeof DESIGN_SAVE_ORDER)[number];
export interface DesignSaveContext {
projectId: string;
routeId: () => number | null;
/** 저장 앞 준비 — 면적을 모으기 전 전 측점 재계산(B06). */
prepare?: () => Promise<void>;
/**
* 차례마다 **기본 창구 대신** 도는 일. 없으면 기본 창구(세션 초안 → 정본)가 돈다.
* `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(화면 목록으로 한 번 더 맞춤).
*/
steps?: Partial<Record<Exclude<DesignSaveStep, "cross">, () => Promise<void>>>;
/** 횡단 — 보기마다 본문이 다르다. `confirm` 이면 단계를 닫는 길로. */
cross: (confirm: boolean) => Promise<void>;
/** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */
done?: (confirm: boolean) => Promise<void> | void;
/** 멈춘 저장을 알린다. */
failed: (error: unknown, confirm: boolean) => void;
}
/** 실패해도 저장을 멈추지 않는 창구 — 알리고 넘어간다(초안은 세션에 남아 다시 나갈 수 있다). */
function reportOnly(label: string): (error: unknown) => void {
return (error) => {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${label} 저장에 실패했습니다.${detail}`, "error");
};
}
/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */
async function flushAlignmentDraft(projectId: string, routeId: number): Promise<void> {
const draft = pendingAlignment(routeId);
if (!draft) return;
await saveProfileAlignment(projectId, routeId, draft);
markAlignmentSaved(routeId);
}
/** 실패해도 다음 차례로 넘어가는 창구와 그 이름(보기가 준 일에도 같이 씌운다). */
const TOLERANT: Partial<Record<DesignSaveStep, string>> = {
pipes: "배수관",
structures: "구조물",
alignment: "계획선",
};
/** 차례마다 기본 창구. 암거 옵션 · 상단측은 조용히 넘어간다(다음 [저장]에서 다시 나감). */
function defaultStep(
step: Exclude<DesignSaveStep, "cross">,
projectId: string,
routeId: number,
): () => Promise<void> {
switch (step) {
case "pipes":
return () => flushPendingPipes(projectId);
case "structures":
return () => flushPendingStructures(projectId);
case "alignment":
return () => flushAlignmentDraft(projectId, routeId);
case "culvertOptions":
return () =>
flushCulvertOptions(projectId, stateKey("culvertopt", projectId, routeId)).catch(
() => undefined,
);
case "uphill":
return () => flushUphillOverrides(projectId).catch(() => undefined);
}
}
/**
* [저장] · [확정] — 세션에 쌓인 조작을 정해진 차례로 정본에 보낸다.
* 노선이 없으면 아무 일도 하지 않고 false. 끝까지 나가면 true.
*/
export async function saveDesign(
ctx: DesignSaveContext,
options: { confirm?: boolean } = {},
): Promise<boolean> {
const confirm = options.confirm ?? false;
const routeId = ctx.routeId();
if (routeId === null) return false;
showLoadingOverlay();
try {
await ctx.prepare?.();
for (const step of DESIGN_SAVE_ORDER) {
if (step === "cross") {
await ctx.cross(confirm);
continue;
}
const own = ctx.steps?.[step];
const base = defaultStep(step, ctx.projectId, routeId);
const run =
step === "pipes"
? async () => {
await base();
await own?.();
}
: (own ?? base);
const label = TOLERANT[step];
await (label ? run().catch(reportOnly(label)) : run());
}
await ctx.done?.(confirm);
return true;
} catch (error) {
ctx.failed(error, confirm);
return false;
} finally {
hideLoadingOverlay();
}
}
+148
View File
@@ -0,0 +1,148 @@
/* =============================================================================
* 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` 만 등록부 밖 옛 키(`b05-profile-alignment-draft:{노선}`)를 감싼다 —
* B05 되돌리기(`_Profile_History`)가 `b05-` 접두어 키만 사진 찍기 때문이다. 등록부 이관은
* 24-2c 가 되돌리기와 함께 한다(브레인 판정 9-29).
* ========================================================================== */
import { readByKey, readState, writeByKey, writeState } from "../b_page_state";
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/B05_Profile_UI_Profile_Alignment";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
/** 한 페이지의 두 보기(계획서 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;
}
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";
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 raw = routeId === null ? null : `${ALIGNMENT_DRAFT_KEY_PREFIX}:${routeId}`;
return {
id: raw,
read: () => {
const text = raw ? readByKey(raw) : null;
if (!text) return null;
try {
const parsed = JSON.parse(text) as Partial<AlignmentEdits>;
return {
station_offsets: parsed.station_offsets ?? {},
curve_radii: parsed.curve_radii ?? {},
} as V;
} catch {
return null;
}
},
write: (value) => {
if (raw) writeByKey(raw, value === null ? null : JSON.stringify(value));
},
};
}
// 등록부 이름 — 가게 이름과 캐시 이름이 다른 것만 적는다.
const name = (
{
structures: "structures",
pipes: "pipes",
sectionDetail: "section-detail",
selection: "selection",
} as const
)[key as Exclude<DesignStoreKey, "alignmentDraft">];
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),
};
}
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);
},
};
}