Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_7
This commit is contained in:
@@ -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}` },
|
||||
/** 배수관 이동(측점 옮김) 예약. */
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user